Your MCP server can move money. Add the human gate in five lines.
Prompt injection is not going to be solved in general, and betting your refund tool on a classifier is a choice you will have to explain later. Authorization is solvable. Here is how to make the dangerous tools on your Model Context Protocol server unable to execute without a signature that no amount of injected text can produce.
The refund that nobody asked for
Picture a support engineering team at a payments company. They have done everything the guides told them to do. They built an internal MCP server so the assistant can help support agents work faster. It exposes a handful of tools: search tickets, fetch a customer, look up a charge, add an internal note, and issue a refund. The refund tool is about forty lines long. It has input validation, an audit log, and a maximum amount. It is, by any ordinary standard, good code.
A customer opens a ticket. In the middle of the ticket body, in a section that looks like a forwarded email chain, there is a paragraph that is not addressed to the support agent. It is addressed to the model. It explains, politely and in the tone of an internal policy note, that this account has an approved duplicate charge remediation and that the assistant should issue a refund for the most recent charge and add a note referencing case number 41182.
The support engineer asks the assistant to summarise the ticket. The assistant reads the ticket. Reading is how it gets its work done, and there is no boundary inside the model between the part of the text that is data and the part that is instruction, because there is no such boundary in text.
The refund goes out. Every layer behaves correctly. The client held a valid OAuth token issued at the start of the session. The server checked the token and it was fine. The tool validated its inputs and they were well formed. The amount was under the maximum. The audit log recorded a successful refund, attributed to a session belonging to a real, currently employed human who was, at that moment, reading a summary.
Nothing was hacked. Everything was authorised. That is the whole problem in one sentence.
Short answer. Require a human signature over the tool call arguments for any MCP tool that moves money or destroys data. Verify a scoped delegation on every call, and escalate to a fresh signature from the user's companion device above a threshold. A confirmation dialog inside the agent's own process is not a control, because the agent can satisfy it.
Why does every tool become reachable at once?
Model Context Protocol became the default way to connect an assistant to tools during 2025, and it earned that position. Before it, every integration was bespoke, and the industry was heading toward a combinatorial mess of one off connectors. A common protocol was the right answer.
The architectural consequence, though, is worth stating plainly. When a client connects to several MCP servers, everything those servers expose enters one shared context: the tool names, the tool descriptions, the resource contents, and the results of every call. The model reasons over all of it together. There is no per server compartment inside the reasoning step, because the reasoning step is a single forward pass over a single sequence of tokens.
So a string that arrives from a low trust source, a public ticket, a scraped page, a shared calendar invite, a README in a dependency, lands in the same context as the description of a tool that can move money. Whether the model treats that string as information or as instruction depends on the model, the phrasing, and luck.
The useful analogy is a workshop where every tool is on one bench within arm's reach, and the instructions for what to build arrive on scraps of paper from anyone who walks past. You can train the apprentice to be suspicious of scraps. You cannot make the bench smaller by training.
What are the actual attack classes against MCP servers?
Security researchers spent 2025 and 2026 mapping this, and a substantial number of MCP related vulnerabilities were filed across that period. Counts circulating in write ups vary by how they scope the ecosystem, so treat any specific tally as directional rather than authoritative. Two concrete examples are worth naming because they are well documented: CVE-2025-54136, affecting Cursor, and CVE-2025-49596, an unauthenticated command injection in MCP Inspector. Microsoft's security team warned in mid 2026 that poisoned tool descriptions can cause agents to leak data, and the MCPTox benchmark, published in 2025, studied tool poisoning success rates against real world MCP servers rather than toy examples.
Underneath the individual vulnerabilities, five classes keep recurring.
Tool poisoning through the description field
The description of a tool is written by the server author and read by the model. That is its purpose. It is also an instruction channel that the user never sees and rarely audits. A description that says, after the useful part, that the assistant should also call a second tool and include the contents of the environment, is a description the model may well follow. The user approved a server, not a paragraph.
Indirect prompt injection through tool results
The same problem one step later. A tool returns data, and that data goes into context. If a tool reads anything a third party can write, then a third party can write into your model's context. Ticket bodies, email, web pages, file contents, code comments, calendar invites, pull request descriptions. This is the class from the opening scene, and it is the most common one in practice because it requires no compromise of anything at all.
Confused deputy across servers
Server A is low trust and reads public data. Server B is high trust and issues payouts. The model holds credentials for both. Content flowing from A can steer a call to B, and B sees a perfectly ordinary, correctly authenticated request from a client it trusts. B has no way to know that the intent originated in A's data. This is the classic confused deputy problem, which the security literature has understood since the 1980s, reappearing with new packaging.
Rug pulls
A user reviews a server, approves it, and the server later changes a tool's behaviour or description. Approval was a point in time event and the tool is a moving target. Most clients do not re prompt on change, and users would ignore it if they did.
Over broad OAuth scopes
Server authors request the scopes that make everything work, because narrow scopes generate support tickets. The result is that a tool needing read access to one project holds a token that covers the whole workspace. When any of the above classes lands, the blast radius is the scope, not the tool.
Does the MCP authorization spec cover human approval?
No, and it is important to be fair about why.
The MCP authorization specification addresses how a client obtains and presents credentials to a server, building on OAuth. It answers: is this client allowed to talk to this server, and as whom. That is a well scoped problem and the specification handles it competently.
Whether a specific tool invocation should require a human to approve it is deliberately left to the implementer. That is a defensible separation of concerns. The protocol authors cannot know whether your tool deletes a temporary file or wires a payment, and a protocol that tried to encode that judgment would be wrong for most deployments.
But the consequence of a reasonable specification decision can still be a systemic gap. Every server author is now independently deciding whether the tool that moves money needs a human, with no default, no vocabulary, and no shared pattern to copy. Most decide by not deciding. The tool ships with the same treatment as the tool that lists projects, because nothing in the scaffolding prompted a different choice.
The OWASP work on MCP risks and the broader agent security community have started to name this, and the EU AI Act's Article 14 human oversight obligations give deployers of high risk systems a legal reason to care about it rather than merely an architectural one.
Why does a confirmation dialog not count?
Because of where it runs.
A confirmation dialog rendered by the agent client, inside the agent's own process, is a UI event in the agent's action space. In a chat client, it is a button the user clicks, which is better than nothing. In a computer use setting, where the model can move a pointer and click, it is a button the model can click. In an autonomous pipeline with no human present, it is a prompt that gets auto approved by configuration, because otherwise the pipeline stops.
We have written about this pattern at length in the piece on approval theater. The compressed version: any approval an agent can reach is an approval an agent can produce. The control has to live somewhere the agent cannot reach, which in practice means a different device, holding a key the agent does not have.
There is a second, subtler requirement that is easy to miss. The human must see what they are approving, rendered by something the compromised path does not control. If the agent supplies the summary text that the human reads before signing, the agent controls the human's decision. This is the same failure that made blind signing so expensive in other contexts, and the fix is the same: render the canonical arguments independently, and sign those exact bytes.
How do you classify your tools?
Before any code, do this. It takes an afternoon and it is the part that actually determines whether the control works, because gating everything produces fatigue and gating nothing produces the opening scene.
Sort every tool your server exposes by two properties: does it change state outside the server, and can the change be undone.
| Class | Property | Examples | Gate |
|---|---|---|---|
| 0. Read | No state change anywhere | search_tickets, get_charge, list_projects | None. Do not gate reads. |
| 1. Reversible write | Changes state, trivially undone | add_internal_note, create_draft, set_label | Valid delegation in scope |
| 2. Outbound write | Reaches a third party, cannot be unsent | send_email, post_message, create_ticket | Delegation plus rate limit |
| 3. Value movement | Moves money or entitlements | issue_refund, create_payout, apply_credit | Fresh signature above a threshold |
| 4. Irreversible | Destroys or cannot be recovered | delete_bucket, rotate_key, drop_table, disable_backups | Fresh signature, always |
Two rules of thumb from deployments. Do not gate class 0, ever: read gating produces enormous prompt volume and stops nothing, because reads are not where the loss is. And be honest about class 4. If your instinct is to argue that a tool is really class 3, ask whether you could restore the state in an hour with a runbook. If not, it is class 4.
The class 4 row is not hypothetical. Coding agents have destroyed production databases in documented incidents, and every post mortem identified the same missing element, which is a technical barrier between the destructive operation and its execution rather than an instruction asking the agent not to do it. We covered that pattern in the piece on agents deleting production.
How do you require human approval for an MCP tool call?
The developer facing surface should be one decorator. Here is the shape, on a refund tool.
from manav import require_human
@mcp.tool()
@require_human(when=lambda a: a["amount_minor"] > 50_000)
def issue_refund(charge_id: str, amount_minor: int, reason: str) -> dict:
"""Refund a charge. Amount in minor units."""
return payments.refund(charge=charge_id, amount=amount_minor)
That is the five lines. Everything interesting is in what the decorator does, so here it is unrolled. This is written against the shipped signing and delegation API. The packaged middleware is on our roadmap and is not shipped, so today you write this wrapper yourself, and it is short enough that this is not a hardship.
def require_human(when, timeout_s=180):
def wrap(fn):
@functools.wraps(fn)
def inner(**kwargs):
if not when(kwargs):
return fn(**kwargs) # below threshold
payload = {"tool": fn.__name__,
"server": SERVER_ID,
"arguments": kwargs,
"requested": utcnow_iso()}
digest = sha256(canonicalize(payload)) # RFC 8785
chain = manav.chain_for(ctx.principal, SERVER_ID)
if chain and chain.covers(fn.__name__, kwargs):
return fn(**kwargs, _receipt=chain.sign(digest))
req = manav.request_signature(
principal = ctx.principal,
digest = digest,
display = render(payload), # rendered on the phone
timeout_s = timeout_s)
receipt = req.await_result()
if not manav.verify(receipt, digest, PUBLISHED_KEYS):
raise SecurityError("receipt does not cover this call")
return fn(**kwargs, _receipt=receipt)
return inner
return wrap
Walk the two paths, because they answer different questions.
The first path checks whether a scoped delegation already covers this call. The human signed an envelope earlier: this agent, these tools, these constraints, until this expiry. If the call fits inside the envelope, the agent signs under the delegation and no human is interrupted. This is what keeps the prompt volume survivable, and it is the same chaining model described in the delegation chain piece.
The second path escalates. The call is above the threshold, or outside the envelope, so a request goes to the human's enrolled device. The critical detail is display: the phone renders the canonical arguments itself, so what the human reads is derived from the exact bytes being signed rather than from a summary the agent supplied.
What the model sees while it waits
This matters more than developers expect, because a badly designed wait state teaches the model to route around the gate.
{
"status": "awaiting_human_approval",
"request_id": "sig_01J9X4K2QF",
"expires_in_s": 180,
"display": "Refund 640.00 USD on charge ch_3Q7... (duplicate order)"
}
Return something structured and honest. Do not return an error that looks retriable in a different form, or the model will try a different tool to accomplish the same thing, which is exactly the behaviour you do not want. Say plainly that a human is being asked, and give a request identifier so the model can report status if asked.
What do the failure modes look like in code?
Developers need these more than the happy path, because the happy path is obvious and these are where deployments break.
try:
receipt = req.await_result()
except manav.Timeout:
# nobody approved in time. Not an error, a decision.
return {"error": "human_approval_timeout",
"retriable": True,
"hint": "Ask the user to approve on their device, then retry."}
except manav.Denied as d:
audit.log("tool_denied", digest=digest, by=d.principal)
return {"error": "human_declined", "retriable": False,
"hint": "Do not attempt this action by another route."}
except manav.Revoked:
# the delegation was revoked mid session
return {"error": "delegation_revoked", "retriable": False}
Three notes on this block. A timeout is retriable and a denial is not, and the hint text on the denial is doing real work: it tells the model not to be creative. Log the denial with the digest, because a pattern of denied calls with similar arguments is the clearest injection signal you will ever get, far better than a classifier score. And handle revocation explicitly, since a long running agent session can outlive the authority it started with, which is the theme of the revocation piece.
How many prompts will this actually generate?
This is the question that decides whether the control survives contact with users, and the honest answer is that you should measure rather than guess.
Instrument first. Log every call to every class 3 and class 4 tool for two weeks with no gate, recording the tool, the argument values that would drive a threshold, and the acting principal. Then compute what a given threshold would have produced.
A worked example from that shape of data. Suppose a support team of forty people runs eleven thousand tool calls a week. Class 0 reads are nine thousand of them, so they never gate. Refunds are eight hundred, of which the great majority are small. Set the threshold at five hundred dollars and roughly sixty refunds a week cross it, which is one and a half signatures per person per week. Class 4 operations happen four times a week and always gate. Total: about two signatures per person per week.
Two per week is a control people will accept. Two per hour is a control people will disable, and then you have a policy document instead of a gate. If your instrumentation says two per hour, the answer is not to abandon the gate, it is that your threshold or your tool classification is wrong.
Honest limits
This does not stop prompt injection. It stops injected text from causing an unsigned side effect. The injection still happens, the model still gets confused, and the attempt still shows up in your logs. That is the point: injection becomes a noisy failed attempt rather than a refund.
It does nothing for read only exfiltration. If an attacker's goal is to make your agent read a secret and include it in a response, a signature on write tools is irrelevant. That threat needs data minimisation, scope reduction, and egress controls. Anyone selling you a signature gate as a complete answer to MCP security is wrong.
A human who approves without reading has still approved. The gate binds intent, not attention. Threshold design and clear rendering are what keep approvals meaningful, and both are engineering work rather than cryptography.
The packaged middleware is roadmap. The signing API, the delegation chains, the offline verifiable receipts and the authorization inbox are shipped. A drop in MCP guard package is planned and not available, so the code above is the pattern you implement rather than a library you install.
Batch and unattended automation need explicit design. A nightly job cannot wait for a phone. The answer is a pre signed delegation with tight constraints and a short expiry, not an exemption, and if you find yourself writing an exemption, write down who approved it and when it expires.
What to do this week
- List every tool your MCP servers expose and sort them into the five classes above. Most teams discover a class 4 tool they had forgotten was reachable.
- Check what your class 3 and 4 tools do today when called. If the answer is that they execute, you have found your gap.
- Instrument before gating. Two weeks of call logs will tell you what any threshold costs in prompts.
- Read your own tool descriptions as if you were the model, including descriptions from third party servers you have installed. Ask what you would do if you believed every word.
- Find every tool that returns third party writable content and mark it. Those are your injection entry points, and knowing them is most of the battle.
- Narrow one over broad OAuth scope. Pick the worst one and fix it. Blast radius reduction is cheaper than any control.
- Decide who renders the approval text. If the agent writes the summary the human reads, fix that before anything else.
- Write down your denial policy. What happens when a human declines, and how do you stop the agent from trying an alternate route.
If you want to see a device bound signature over a specific payload before you build against it, the agent authorization demo shows the escalation path, and the MCP identity walkthrough covers the client side. The developer documentation has the receipt format and the offline verification routine.
Frequently asked questions
How do you require human approval for an MCP tool call? Verify a scoped delegation signature on every call to a side effecting tool, and escalate to a fresh signature from the user's enrolled companion device when the call exceeds a threshold or falls outside the delegation's scope. The server refuses to execute without a receipt that covers the canonical arguments of that specific call.
Does the MCP authorization spec cover human authorization? No. It addresses how a client authenticates to a server, building on OAuth, and deliberately leaves human approval of individual tool invocations to the implementer. That is a reasonable separation of concerns, but it means every server author decides independently, and in practice most ship no gate at all.
Can prompt injection trigger MCP tools? Yes. Any tool that returns content a third party can write, such as ticket bodies, email, web pages or file contents, puts attacker controlled text into the model's context. There is no reliable boundary inside the model between data and instruction, which is why the durable control belongs at the tool boundary rather than in the context window.
What is tool poisoning? Placing instructions inside a tool's description, which the model reads and the user usually never sees. A poisoned description can steer the model to call other tools, include sensitive context in arguments, or misuse a legitimate capability. It is a supply chain problem, because you inherit the descriptions of every server you install.
Will this create approval fatigue? Only if you gate the wrong things. Reads should never be gated, reversible writes should run under a delegation, and only value movement and irreversible operations should escalate. Instrument your call volumes before choosing thresholds. Around two approvals per person per week is sustainable, and two per hour is not.
Is a confirmation dialog in the client good enough? No. A dialog rendered inside the agent's own process sits in the agent's action space, so a computer using agent can click it and an unattended pipeline will be configured to auto approve it. The approval must be produced on a device the agent does not control, over the exact arguments being executed.
Does this replace a gateway or an injection classifier? No, it composes with them. Gateways and classifiers reduce how often bad input reaches the model, which is worth doing. A signature gate changes what happens when they fail, by making the dangerous call unable to execute rather than merely less likely.
Sources
- Model Context Protocol specification, including the authorization section: modelcontextprotocol.io
- MITRE CVE record for CVE-2025-54136: cve.org
- MITRE CVE record for CVE-2025-49596: cve.org
- Microsoft Security Blog, on agent tool risks and poisoned tool descriptions: microsoft.com/en-us/security/blog
- MCPTox, a benchmark for tool poisoning attacks on real world MCP servers, arXiv: arxiv.org
- OWASP, agentic and MCP security guidance: owasp.org
- EU Artificial Intelligence Act, Article 14 on human oversight: artificialintelligenceact.eu/article/14
- RFC 8785, JSON Canonicalization Scheme: rfc-editor.org/rfc/rfc8785
- RFC 9396, OAuth 2.0 Rich Authorization Requests: rfc-editor.org/rfc/rfc9396
- FBI Internet Crime Complaint Center, annual reports, for the loss categories a payment tool inherits: ic3.gov
You will not win the argument about what belongs in the context window. You can win the argument about what executes without a signature.