MCP Write Controls: Lessons from Cloudflare WriteGuard
MCP write controls decide what your agents can break. Cloudflare's WriteGuard shows the pattern: per-tool risk tiers, agent attribution, central audit.

A ticketing system starts closing bugs at noon. By 4 p.m., thousands of tickets are closed — all of them attributed to one engineer named Joe. Joe is a good engineer. Joe is not a thousand-tickets-an-hour engineer.
MCP write controls are what stop that afternoon: server-side policy that decides, before a tool’s handler ever runs, whether an agent’s write happens, whose name it carries, and what record it leaves. Almost nobody building an MCP server has any.
That ticketing story is the opening of Cloudflare’s WriteGuard announcement, and the reason it took half an hour to find the culprit is the interesting part. Joe had several background agents running across three concurrent sessions. The ticketing system recorded every change under Joe, because it was Joe’s credentials. The network logs couldn’t tell one agent session from another. From the outside, a human closing a ticket and an over-eager cleanup prompt closing ten thousand look identical.
This is what happens the moment an MCP server stops being read-only. MCP write controls are the layer most teams skip, and it’s the one that decides how bad the bad day gets.
TL;DR
- MCP write controls are server-side policy applied between the tool call and the tool handler — allow, label, or block. If the control lives in the client, it isn’t a control.
- Cloudflare’s internal MCP portal went from 13 servers in April to 27 today, all starting read-only. Write access is what forced the governance layer.
- The pattern is three jobs in one middleware: per-tool risk tiers (policy), agent session identity injected into the write (attribution), and a scrubbed async event per call (audit).
- Client-side guardrails don’t hold. In Cloudflare’s words, skills and elicitation prompts “vary by harness, and users can disable them.”
- MCP’s own
readOnlyHint/destructiveHintannotations are hints — the spec tells clients to treat them as untrusted unless the server is trusted. They describe a tool; they don’t authorize a call. - The cheapest version of this is one config file mapping tool → risk tier, one wrapper around your dispatcher, and one structured log line per invocation.
What are MCP write controls?
MCP write controls are policy, attribution, and audit applied to a tool call on the server side of the protocol — after the client sends tools/call, before the tool’s handler executes — deciding whether the action runs, whose identity it carries downstream, and what record it leaves behind.
The definition earns its keep on one word: before. Every other place you might put this logic — the system prompt, a client-side skill, a confirmation dialog in the agent harness — sits upstream of the wire and can be bypassed by changing harnesses. The handler is the last place you control, so it’s the only place a rule becomes a guarantee.
If you’re building on the protocol itself first, I’ve covered the mechanics of building an MCP server and deploying one on Cloudflare Workers separately. This post is about what you add once those tools can change something.
Why client-side guardrails don’t hold
Cloudflare is blunt about why they didn’t push this into the client: “We could not count on client-side controls such as skills or elicitation prompts. Their behavior varies by harness, and users can disable them.”
That sentence is worth reading twice, because it invalidates the default plan most teams have. The default plan is: put “never merge without asking” in the system prompt, enable the client’s confirmation UI, and trust the loop. Three things break it.
- Harness diversity. The same MCP server is reached from Claude Code, from an IDE plugin, and from a long-running agentic service with no interactive user at all. Only the first two have a confirmation UI to disable.
- User discretion. Anyone who has watched an engineer auto-approve their way through a long agent session knows the confirmation prompt has an off switch, and that the off switch is used.
- Speed. An agent can repeat an action faster than a human can read the notification about the first one. Controls that depend on a human noticing lose the race by design.
The MCP specification agrees on the goal and is honest about its own limits. It says there SHOULD always be a human in the loop with the ability to deny tool invocations — a SHOULD aimed at applications, i.e. the clients that just failed all three tests above. The same page’s security section puts the hard requirements on servers: validate inputs, implement access controls, rate limit invocations. That’s the split. Clients get advice; servers get obligations.
Risk tiers: the tool is the unit of policy
WriteGuard attaches configuration to each tool rather than to each prompt: a risk tier, an enabled/disabled flag, and a labeling format. Four tiers, with Cloudflare’s own GitLab examples:
| Risk tier | What’s in it | GitLab example | Typical policy |
|---|---|---|---|
| Read only | Search, retrieval, status | get_merge_request | Pass through unchanged |
| Minimal impact | Reactions, notifications, subscriptions | Mark a notification read | Allow, log |
| Contained write | Comments, new MRs, field updates | create_mr_note | Allow + attribute + audit |
| Critical | Merges, prod deploys, bulk deletes | merge_mr | Disabled — block before the handler |
Follow one call through each. get_merge_request is classified READ_ONLY and passes through untouched. create_mr_note is CONTAINED_WRITE, so the layer injects agent attribution into the configured note field using a format GitLab renders, calls the handler, and asynchronously records a scrubbed audit event. merge_mr is CRITICAL and configured disabled, because merges at Cloudflare trigger deployment pipelines — so when an agent “helpfully” calls it unasked during a review, the call is blocked before the handler runs and the attempt is recorded.
Two design choices in there are worth stealing outright.
Risk is a property of the tool, not of the request. You cannot classify danger by inspecting the arguments of one call — merge_mr is dangerous when it’s obviously reasonable, which is precisely when a persuaded agent will call it. Tier the tool once, at config time, when nobody is under pressure.
Blocking is a tier, not an exception. “Disabled” isn’t a broken state to be fixed; it’s the correct steady state for tools whose blast radius exceeds what an unattended loop should own. Cloudflare defines this configuration in TypeScript in an internal MCP monorepo today, with portal-based configuration coming as the private beta expands — but the shape matters more than the storage. A tool without a tier is a tool nobody has thought about.
Attribution: the identity gap in every agent stack
Here’s the part almost nobody builds, and it’s the part that made the Joe incident take thirty minutes instead of thirty seconds.
Cloudflare’s MCP servers identify the human user, so an agent operates with that employee’s permissions. If Joe can’t close an issue, Joe’s agent can’t either. They deliberately rejected standalone agent accounts: a second permission set to manage, and a weaker link between the action and the person responsible for it. I think that’s the right call, and it’s the one I’d make too — but it has a cost they name plainly. The downstream application sees Joe’s credentials and nothing identifying the agent behind the action.
Authorization and accountability are not the same axis. Your permission model answers may this action happen. Attribution answers who and what did it — and with agents in the loop, “Joe” is no longer a sufficient answer to the second question, even when it’s the complete answer to the first.
WriteGuard closes the gap by adding MCP client and session context to the human identity, so each write reads as an agent session acting on behalf of a specific person, with a visible label in the downstream tool.
The line I keep coming back to is Cloudflare’s aside that this attribution “is extremely useful even when nothing goes wrong” — it helps humans and other agents interpret a change and decide how to respond. A comment on your MR that says which agent session wrote it is a different comment. That’s the same boundary problem I wrote about in agent-to-human delegation: the handoff only works if the receiving side can tell what it’s receiving.
Audit: three outcomes, zero latency
Labels explain one action in one place. They don’t give you a fleet-wide view, and an agent repeats actions far faster than a person, so central auditing is the other half.
WriteGuard classifies each invocation as successful, failed, or blocked, then asynchronously ships a scrubbed event — values for sensitive keys omitted — carrying the server, tool, risk tier, outcome, user, client, and duration. Asynchronous is the load-bearing word: logging adds no latency to the response the agent is waiting for.
Three notes from having built adjacent things:
- Blocked is the row you’ll actually query. Successes are volume; blocks are the record of policy doing its job, and a rising block count on one tool is your earliest signal that a prompt somewhere has gone too broad.
- Risk tier belongs in the event. It’s what makes “show me every critical-tier attempt this week” a query rather than a join against a config file you’ll forget to keep in sync.
- Scrub at emit time, not at read time. Sensitive values you never wrote down cannot leak from the audit store, and the audit store is the one system guaranteed to have seen every argument to every call.
The build-vs-buy line, and where I’d draw it
Cloudflare’s justification for a shared layer over per-server logic is the standard platform argument, and it’s correct: they could have built these controls into the GitLab server alone, but they needed the same behavior for Jira, the internal wiki, Google Workspace, and every new server shipping each month. Reimplementing per server means more work and inconsistent behavior — the second cost being the real one, since inconsistent security controls are the kind that fail in the gap between two implementations.
WriteGuard itself is in private beta as an MCP portal feature, expanding toward general availability while Cloudflare validates how the risk model maps to customer tools and what audit delivery guarantees customers need. So if you’re running one MCP server today, the honest answer is that you don’t need a portal — you need the shape.
The smallest version that pays for itself:
- A tier table. One file mapping every tool name to
read_only | minimal | contained_write | criticalplus anenabledboolean. Adding a tool means adding a row; that’s the forcing function. - One choke point. Wrap tool dispatch so every call goes through the check. Not per-handler
ifstatements — the one you forget is the one that closes ten thousand tickets. - Attribution on every write. Whatever field the downstream system renders, put the client and session in it. Text is fine.
- A structured line per call with tool, tier, outcome, user, session, duration — emitted off the response path.
- Default deny for new tools. An untiered tool should be disabled until someone tiers it, which is the inverse of how most codebases treat a missing config value.
Steps 1 and 2 are an afternoon. They’re also the difference between “we turned it off” and “we’re reading the network logs.” That gap between a working demo and something you’d leave running unattended is the same one I mapped in the vibe-to-live gap for production agents — and write controls are one of the load-bearing pieces on the far side of it.
Where the spec helps, and where it stops
MCP does have vocabulary for this. Tool annotations in the 2025-06-18 schema include readOnlyHint (default false), destructiveHint (default true, meaningful only when readOnlyHint is false), idempotentHint (default false), and openWorldHint (default true).
Read those defaults carefully — they’re well chosen. An unannotated tool is assumed to be a destructive, non-idempotent, open-world write. The spec’s stance is that silence means danger, which is exactly the default-deny posture from step 5 above.
But the spec then attaches a warning that settles the architecture question: “clients MUST consider tool annotations to be untrusted unless they come from trusted servers.” Annotations flow server → client as self-description. A control needs to flow the other way — enforcement applied to an incoming call, by the party that owns the consequences. And annotations describe a tool, so they can’t express “this session has already done this 400 times” or “this user may comment but not merge.” They’re the right metadata for rendering a confirmation prompt. They are not policy.
The gap between those two things is where the whole class of agent-write incidents lives — including the adversarial ones, where the goal isn’t a broad prompt but a poisoned input steering the agent toward a tool it should never touch. A tier table and a choke point stop both, because neither cares why the call arrived.
FAQ
What are MCP write controls? MCP write controls are server-side policy applied to a tool call after the client sends it and before the tool’s handler runs. They decide whether the call is allowed at all, what identity gets attached to the resulting change, and what audit record the call produces. The distinguishing feature is the enforcement point: a control that lives in the agent’s client, prompt, or system instructions is advice, because the agent or the user can route around it.
Why aren’t MCP tool annotations enough to secure write tools? Tool annotations like readOnlyHint and destructiveHint travel from the server to the client as self-description, and the MCP spec explicitly tells clients to treat them as untrusted unless the server is trusted. They also describe a tool rather than authorize a call, so they carry no notion of who is calling, from which session, or how many times. They are useful for rendering a confirmation prompt, not for enforcing a policy.
Should agents get their own accounts on downstream systems? Usually not. Cloudflare deliberately kept agents on their human sponsor’s permissions rather than issuing standalone agent accounts, because a separate account creates a second permission set to maintain and severs the link between an action and the person responsible for it. The cost of that choice is that the downstream system sees only the human’s credentials, which is exactly the gap an attribution layer has to fill.
What belongs in an MCP audit event? Enough to answer “which agent session did this, on whose behalf, and what happened” without storing secrets. Cloudflare’s events carry the server, tool, risk tier, outcome, user, client, and duration, with values for sensitive keys scrubbed. Classify the outcome as succeeded, failed, or blocked — blocked attempts are the highest-signal rows in the table, because they are the record of policy actually stopping something.
Does an audit layer slow down tool calls? It does not have to. Cloudflare sends audit events asynchronously so that logging adds no latency to the response the agent is waiting on. The policy check itself is synchronous and must be, since its whole job is to run before the handler, but that check is a configuration lookup rather than a network round trip.
What is the smallest useful version of this for my own MCP server? Tag every tool with a risk tier and an enabled flag in one configuration file, wrap your tool dispatcher so that every call passes through a single check, and emit a structured log line per invocation with the outcome. That is a day of work and it gives you the two things you cannot reconstruct after an incident: a place to turn a dangerous tool off, and a record of who called what.
The takeaway
Read-only MCP servers are a solved problem and a comfortable place to stay. Every team I know is leaving it anyway, because search-only tools stop being interesting about a week after you ship them.
The moment you cross that line, three questions get asked in an incident review, and only one of them is about permissions. Could the agent do this? — your existing authz answers that. Which agent session did it, on whose behalf? — nothing answers that unless you built attribution. What else did it try? — nothing answers that unless you logged blocks.
Cloudflare built a shared layer because they had 27 servers. You probably have one. The tier table still fits in a single file, and it’s a much better artifact to have written before the afternoon the tickets start closing.
Sources
- Cloudflare, WriteGuard: fine-grained controls for MCP Servers — the Joe incident, the four risk tiers, the GitLab walkthrough, portal server counts, and the private-beta scope.
- Model Context Protocol, Tools specification (2025-06-18) — the human-in-the-loop
SHOULD, the untrusted-annotations warning, and the server/client security requirements. - Model Context Protocol, schema.ts, 2025-06-18 — the
ToolAnnotationsfield definitions and their defaults.
Related Articles

AI Security
How to Secure MCP Write Tools: Cloudflare's WriteGuard Lesson
How to secure MCP write tools: the spec puts confirmation on the client — the one layer you don't control. Cloudflare's WriteGuard moves the gate server-side.

AI Security
Insider Threat Offboarding Controls: The Apple v. OpenAI Lesson
Insider threat offboarding controls, read through Apple v. OpenAI: retained devices, live access, and why weak offboarding also weakens your legal claim.

AI Security
LLM Abuse Detection: What OpenAI's Scam Ban Reveals
LLM abuse detection failed at the message level and worked at the account level. OpenAI's Cambodia scam ban shows which signal actually catches misuse.
Keep reading
Get new posts on AI, Claude Code & LLMs
New deep-dives on AI engineering, Claude Code, and developer tooling — follow along however you prefer.
About the Author
Software engineer writing about AI, Claude Code, LLMs, OpenAI, Anthropic, and developer tooling. 5+ years building production systems at Expedia Group, Tekion, and BYJU'S.