Drafted with AI help, human-reviewed by The Agent Loop.
Short version: MCP can describe a tool as dangerous. Almost nothing in the stack is required to treat it that way. Annotations get dropped, hosts gate at the UI, servers assume someone else enforced it. I read the spec-shaped complaints, one arXiv study, and a Vercel issue that shipped a checkout tool with zero approval events. The gap is not “we forgot one config flag.” It is that permission was never a protocol primitive.
For skimmers
- MCP auth today ≈ optional transport OAuth, not per-tool policy
-
destructiveHint/readOnlyHintare hints; a client can ignore them (see repro below) - Host “approve?” dialogs live in the UI; MCP servers often never hear about them
- Scanners: 71% of 100 MCP servers scored F on one 2026 framework (vendor research, label it)
- Today’s fix: enforce server-side (or a gateway), default deny for write/delete, log every call
What MCP actually specifies
The Model Context Protocol standardizes how models find and call tools. It does not standardize:
- which agent may call which tool
- read vs write vs destroy as a capability the runtime must enforce
- call budgets, rate limits, or session budgets
- an audit schema every server must emit
A practitioner who shipped five security MCP servers (EDR, proxies, bug-bounty APIs) wrote the same conclusion I keep hitting: every server invents its own ACL because the protocol leaves authorization to the implementer (johnmatrix, Jul 2026). That is a design choice, not a temporary bug.
Tool metadata does include safety-ish fields in the schema (readOnlyHint, destructiveHint, idempotentHint). See the MCP schema. Metadata is not enforcement.
The repro that made me stop
Vercel AI SDK issue #20184 walks through an official-style MCP commerce example:
| Arm | Setup | Result |
|---|---|---|
| 1 | Example wiring as shipped |
submit_order created (2500¢), approval events: 0
|
| 2 | Inspect converted tool | no needsApproval; destructive annotations gone |
| 3 | Explicit policy wrap | approval gate present, execution blocked |
The conversion path kept annotations.title and dropped the rest. The server said “destructive, not idempotent.” The client never asked a human.
That is the whole blog post in three rows: declare ≠ decide ≠ enforce.
Numbers worth knowing (with labels)
| Claim | How to treat it |
|---|---|
| 97.1% of 856 tool descriptions had ≥1 “smell”; 56% unclear purpose | Peer-style arXiv study of 103 servers (2602.14878) |
| 71% of 100 servers scored F; 163 hallucination-based vulns | Vendor scanner research (AgentsID, 2026): directionally useful, not neutral audit |
| ~8.5% of servers implement OAuth | Secondary industry write-up; treat as order-of-magnitude |
| 1,862 public MCP servers answered unauthenticated | July 2025 scan cited in CSA note |
| Tool-poisoning peak success 72.8% (mean 36.5%) | AAAI 2026 paper as summarized in secondary coverage |
I do not need all five to make the point. Even one exploded description field plus one zero-approval checkout is enough: the dangerous path is the default path.
Why host “Allow” is not a security boundary
Interactive hosts (Claude Code, desktop apps, IDEs) ask you to approve tool calls. Good UX. Three leaks:
- Different process. The MCP server may only see “a tool ran,” not “a human clicked Allow on this argument set.”
- Different rules. If the host forgets the gate (example above), nothing downstream re-checks.
- Persuasion. Prompt injection (our last post) aims at the reasoning layer. Policy that lives only in the model’s head can be talked into a “yes.” Execution-time checks do not care how nice the story was.
What to do before MCP grows a real permission model
I would not wait for a standards working group.
-
Default deny writes. Read tools can be broad;
create/delete/send/paystart blocked. -
Enforce outside the model. Gateway or server-side policy (OPA, Cedar, even a boring allowlist in config). Bedrock AgentCore and Vercel’s
@ai-sdk/policy-opaexist because people hit this wall. -
Never ship
mcpClient.tools()straight into the model loop for anything with side effects. Filter, wrap, or gate first. - Pin and re-approve tool lists. Rug pulls change definitions after day-one approval; re-prompt on schema diff.
- Scope credentials per server. One god-API-key in an env var is a prompt injection away from your whole cloud account.
- Log tool name + args + policy verdict. If you cannot answer “what did it call last night,” you cannot incident-respond.
Bottom line: treat MCP like a plugin system for a probabilistic process. Annotations are comments. Something boring and deterministic has to say no.
FAQ
Does MCP have built-in permissions?
No first-class per-tool permission model. Auth work targets transport identity (OAuth), and authorization is largely optional / implementation-defined.
What do destructiveHint and friends actually do?
They declare intent in the tool metadata. Clients are supposed to use them. Issue #20184 shows a conversion path that did not, and a checkout tool ran with no approval.
So is MCP insecure?
MCP is a connection protocol. Security is whatever your host, server, and gateway implement. Most of the ecosystem implements less than the spec’s own “shoulds.”
Where do I start if I run an MCP server tomorrow?
Server-side authz on every mutating tool, unique credentials, deny-by-default for dangerous verbs, audit log. Then argue about protocol politics.
Sources
- Prompt injection in 60 seconds (our post)
- MCP tool schema (annotations)
- vercel/ai #20184: commerce example, annotations dropped, 0 approvals
- MCP permission models are broken (practitioner writeup)
- arXiv 2602.14878: MCP tool description smells (856 tools)
- CSA: MCP security crisis research note (2026)
- Policy-as-code enforcement for agent tool calls
- The MCP authentication and authorization gap
Related on The Agent Loop
Over to you: Has an MCP approval gate ever silently not fired for you? What broke? Reply below — I read every comment.
If you want the rest of this series when it drops: subscribe via Buttondown. If your approval prompt is still sitting there after a month, tell me what you do about it now. I have a guess, and it's not pretty.
Top comments (4)
The "something boring and deterministic has to say no" line is the clearest summary of the problem I've seen. The tricky part is deciding where that deterministic thing lives. Server-side enforcement only sees what the model chose to send. Host dialogs can be skipped, forgotten, or persuaded. And by the time you realize the gate was open, the side effect has already happened.
What I've found useful is splitting the flow into two steps. First the agent proposes a concrete action — call X with args Y, spend within limit Z — signed with the agent's own cryptographic identity. Then authorization happens deterministically against that signed proposal, not against whatever the model next says. If the model gets talked into something different in the next turn, the gate is still checking the original signed commitment.
We handle this at CAI with a hosted action model where agents hold keys and humans approve bounded proposals. The key insight is moving enforcement out of the model's reasoning loop entirely. The model suggests. Infrastructure decides.
That separation is exactly the architecture your post points toward.
This is the missing half of the argument, and I think you've named why my "server-side vs host" split was too coarse. The enforcement point doesn't just decide where the check runs, it decides what gets checked — and if what it checks is the model's next utterance, you've put a deterministic gate around a non-deterministic source.
Signing the proposal before authorization is the part I hadn't spelled out. It converts "the model said it's fine" into "the model committed to this exact call, these args, and this spend ceiling, and here is the signature," which is a much smaller thing for a boring policy engine to verify.
Two questions I'd genuinely like your read on:
Genuinely curious — this is the part of my post I'd rewrite if I were writing it today.
We ran into something close to this testing an agent with both a read-orders and a cancel-order tool: the cancel fired twice in a row because our host dialog timing was off and the server received both calls before either one could be stopped. The CAI reply above is the part that stuck with me, specifically the idea of committing before the next turn rather than evaluating at execution time. What I haven't worked out is what to do with proposals that get signed but never submitted, since we had a few of those surface in postmortems that nobody had thought to expire. That's the piece I'd want answered before shipping anything serious with this approach.
Two things here.
The double cancel is the failure mode I was pointing at from the other side: the gate decided once, but execution happened twice, because the check and the call are two separate events with no binding between them. A signed proposal closes that gap by making the approval refer to one specific call - the second cancel is a different message and needs its own signature, so a stale dialog can no longer replay it.
On signed-but-never-submitted: that is the real gap and I don't think the post answered it. My working answer is that a proposal should carry a short expiry (minutes, not hours) and be single-use, so an unsubmitted one dies quietly instead of lingering as an approvable artifact. The postmortem case you describe - nobody thought to expire them - is exactly what an expiry turns from a surprise into a non-event. If you still have those traces, the age of the oldest orphaned proposal would make a good data point: if they were hours old, the lifetime question is practical; if they were seconds old, it's theoretical.