Key Takeaway
Cloudflare WriteGuard is a server-side layer in its MCP server portal that passes a tool call unchanged, adds agent attribution plus a scrubbed audit event, or blocks it before the handler runs. It exists because client-side approval prompts vary by harness and users can disable them, so they cannot serve as authorization.
An engineer's three background agents closed thousands of tickets in a single afternoon because a cleanup prompt was too broad. Cloudflare says it took about half an hour to find the cause, and the audit records could not separate what the agents did from what the engineer did. That afternoon is the reason WriteGuard exists.
This post is a close reading of Cloudflare's WriteGuard announcement of 5 August 2026 against InfoQ's coverage, the portal documentation and the MCP specification. I have not run WriteGuard, because it is in private beta. Every line of code below that goes beyond what Cloudflare published is labelled as mine.
The afternoon that justified a policy layer
Cloudflare's internal MCP server portal connected 13 MCP servers in April and 27 by the time of the post. Every server started read-only. Then teams across engineering, product, design, sales and customer success asked for tools that write, and a write tool changes the blast radius of a bad prompt from one wrong answer to many wrong records.
The ticket incident failed twice, and the two failures need different fixes. Nothing limited what the agents could do, and nothing recorded whose agents they were. Cloudflare's stated conclusion is that it could not depend on every employee to configure every agent perfectly or watch every tool call, so the control had to sit somewhere the employee does not touch.
Why an approval prompt is not authorization
The MCP specification puts the decision in the client, and it words the duty softly. The Tools page says there SHOULD always be a human in the loop with the ability to deny tool invocations, and that clients SHOULD prompt for confirmation on sensitive operations. The Elicitation page says clients SHOULD let users decline at any time. The same Tools page says clients MUST treat tool annotations as untrusted unless they come from trusted servers. That is the right design for user experience and the wrong place for policy, as the table sorts out.
| Layer | Who enforces it | Can the user switch it off | What it knows about the call |
|---|---|---|---|
| Client confirmation prompt | The client application, as a SHOULD in the spec | Depends on the harness; Cloudflare says users can disable these controls | The call, in that one client only |
| Tool annotations (readOnlyHint, destructiveHint) | Nobody: they are hints a client may read | Not applicable; the spec says treat them as untrusted unless the server is trusted | Declared behaviour, not verified behaviour |
| Portal Access policy and tool allowlist | Cloudflare Access at the portal; admins can turn tools off or expose only an allowlist | Set by admins per server and per portal, not by the end user | Which servers and tools a person may reach, not a risk judgement per call |
| WriteGuard | The portal, before the tool handler runs | Server owners set it per tool; the design goal is that individual users do not | Every call that reaches it, with tier, outcome, user and client |
Read the table as layers, not a replacement. A client prompt still helps the human at the keyboard, and Access still decides who sees which server. What WriteGuard adds is the fourth row: a decision made at a place the agent's operator cannot turn off. That row only counts for traffic that actually goes through the portal, which section 7 returns to.
Three outcomes: pass, attribute, block
For each call, WriteGuard can pass it through unchanged, attribute a supported write by adding agent labels and emitting a scrubbed audit event, or block it before the tool's handler runs. It works across every MCP server behind the portal without changes to each server's code. Cloudflare walks through three GitLab tools to show all three outcomes.
| Tool | Risk tier | What WriteGuard does | What is recorded |
|---|---|---|---|
get_merge_request | Read Only | Passes through unchanged | Logged according to its tier; audit data can be queried by tier |
create_mr_note | Contained Write | Adds agent attribution to the note field in a GitLab-supported format, then runs the handler | Scrubbed event with user, tool, outcome and agent identity |
merge_mr | Critical, configured as disabled | Blocked before the handler runs | The attempt, recorded as blocked |
Cloudflare disables merge_mr because a merge typically triggers a deployment pipeline and a human must stay in the loop. Notice what disabled means here: not ask the person, but the agent cannot do this through this path at all. The post describes no approval-request path for blocked tools. It blocks and records, and the human merges in GitLab with their own click.
Four risk tiers and a three-setting config
Every tool gets three settings: a risk tier, an enabled flag, and a labeling configuration. The tier decides whether an action is logged and whether the call is permitted. Cloudflare's examples for the four tiers are these.
- Read Only: search issues, read a merge request, view pipeline status.
- Minimal Impact: add a reaction, mark a notification as read, subscribe to an issue.
- Contained Write: add a comment, create a merge request, update an issue field.
- Critical: merge a merge request, trigger a production deployment, bulk-delete records.
The config is TypeScript in Cloudflare's internal MCP monorepo today, with server owners expected to set it through MCP server portals later. Labeling inserts attribution into a named field using a text format the downstream app accepts; the post's example offers PLAIN_TEXT and HTML. One discrepancy to know about: InfoQ lists adding a comment under Minimal Impact, while Cloudflare's post puts it under Contained Write and its own create_mr_note example is Contained Write. I follow the Cloudflare post.
// Verbatim shape from Cloudflare's post: one tool, three settings
// (a risk tier, an enabled flag, and a labeling block).
const sendEmailTool = {
tool: EmailMCP.sendEmailTool,
writeGuard: {
riskLevel: RiskLevel.CONTAINED_WRITE,
enabled: true,
labeling: {
field: "body",
supportedFormats: [LabelFormat.PLAIN_TEXT, LabelFormat.HTML],
},
},
};
// My reconstruction of the GitLab walkthrough in the same shape. The post
// describes these three outcomes in prose; it does not publish this config,
// and the labeling field below is a placeholder, not GitLab's real name.
const merge_mr = {
tool: GitLabMCP.mergeMrTool,
writeGuard: {
riskLevel: RiskLevel.CRITICAL,
enabled: false, // blocked before the handler runs; the attempt is audited
},
};Keep your own tier list as a reviewable file in version control. The tier is a human judgement, so a tool labelled Contained Write that really edits many records passes exactly like a small one. Review a tier change the way you review a permission change. This is my advice, not Cloudflare's.
Identity: one human, no agent accounts
Internal servers identify the user through Cloudflare Access and OAuth, and agents act with that employee's permissions. Cloudflare chose not to create standalone agent accounts, to avoid a second permission set and to keep responsibility clear. Downstream applications therefore see the human's credentials, and WriteGuard adds MCP client and session context so each write reads as an agent session acting for a specific person.
The trade-off is inheritance: the agent can do everything its person can, which is exactly why the ticket incident was possible. Service accounts would give cleaner attribution but a second permission model that drifts from the first. Cloudflare picked one model, and my reading is that for internal tools this is correct, with the consequence that the guard is the only thing that narrows an agent below its human.
The audit trail: successful, failed, blocked
Each invocation is classified as successful, failed or blocked, and a scrubbed event goes asynchronously to an internal audit Worker, so it adds no latency to the agent's response. Values under keys considered secret are omitted. The event carries server, tool, risk tier, outcome, user, client and duration. Portal request logs already show tool invocations; WriteGuard adds the semantic classification, the agent context and the backing server's outcome. InfoQ summarises it as sending failed and denied requests to auditing, while the Cloudflare post lists all three outcomes; I follow the post. The code below is my sketch of the same pattern.
// A sketch of the same pattern for a server you own. This is MY code, not
// WriteGuard's: the post publishes no implementation, only the behaviour.
type Tier = "READ_ONLY" | "MINIMAL_IMPACT" | "CONTAINED_WRITE" | "CRITICAL";
type Outcome = "successful" | "failed" | "blocked";
interface ToolPolicy { tier: Tier; enabled: boolean; labelField?: string }
interface Ctx { server: string; user: string; client: string; sessionId: string }
const POLICY: Record<string, ToolPolicy> = {
get_merge_request: { tier: "READ_ONLY", enabled: true },
create_mr_note: { tier: "CONTAINED_WRITE", enabled: true, labelField: "body" },
merge_mr: { tier: "CRITICAL", enabled: false },
};
// The post says values under secret-looking keys are omitted. It does not say
// which fields carry values, so this logs argument keys with scrubbed values.
const SECRET_KEY = /token|secret|password|authorization|api[_-]?key/i;
const scrub = (args: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(args).map(([key, v]) => [key, SECRET_KEY.test(key) ? "[omitted]" : v]),
);
export async function guarded(
ctx: Ctx,
tool: string,
args: Record<string, unknown>,
handler: (a: Record<string, unknown>) => Promise<unknown>,
) {
const started = Date.now();
const policy = POLICY[tool];
// Fire and forget: the audit write must never add latency to the agent.
const emit = (outcome: Outcome) =>
void fetch(process.env.AUDIT_URL!, {
method: "POST",
body: JSON.stringify({
server: ctx.server, tool, tier: policy?.tier ?? "UNCLASSIFIED",
outcome, user: ctx.user, client: ctx.client,
durationMs: Date.now() - started, args: scrub(args),
}),
}).catch(() => undefined);
// Default-deny is MY choice. The post does not say what happens to a tool
// nobody classified; an unlisted tool is the case that should fail closed.
if (!policy || !policy.enabled) {
emit("blocked");
throw new Error("blocked by write policy: " + tool);
}
const field = policy.labelField;
const next =
field && typeof args[field] === "string"
? { ...args, [field]: args[field] + "\n\n[written by an AI agent for " +
ctx.user + " via " + ctx.client + ", session " + ctx.sessionId + "]" }
: args;
try {
const result = await handler(next);
emit("successful");
return result;
} catch (err) {
emit("failed");
throw err;
}
}Three things in the sketch differ from a client prompt. The block happens before the handler, the audit write is not awaited, and an unlisted tool fails closed. That last choice is mine: the post does not say what happens to a tool nobody classified. Fire-and-forget also means an event can be lost if the audit endpoint is down, and Cloudflare lists the audit delivery guarantees customers need as something it still has to validate.
Where it stops: trade-offs and open questions
WriteGuard is a private beta for Cloudflare MCP server portals, rolling out over the coming months. Cloudflare says it still has to validate how the risk model maps to customer tools, which downstream applications need which attribution formats, and what audit delivery guarantees customers require. InfoQ notes the post gives no performance overhead, pricing or detailed criteria for assigning tiers beyond the examples. I found none either, so I have none to give you.
Three gaps follow from the design as described, and these are my inferences rather than Cloudflare's claims. Classification is per call and made by hand, so if a tool that closes one ticket is filed as Contained Write, a loop of ten thousand closes passes ten thousand checks; the post describes no volume cap. The guard governs only traffic that reaches it. And the portal documentation lists limits worth knowing: only remote HTTP servers, up to 80 servers per portal, and no independent MFA or purpose justification for servers authorised through a portal.
A guard is only as strong as the path through it. The portal documentation warns that blocked users can still connect to a server by its direct URL and bypass Access policies, unless Access is configured as that server's OAuth provider. The docs do not say how WriteGuard behaves on a direct connection, so confirm that no write-capable server is reachable without passing the portal.
Run the ticket incident through it honestly. Under WriteGuard as described, you would see which agent session acted for which engineer, and an owner could disable the close-ticket tool outright. You would not automatically be stopped from thousands of individually permitted writes. Attribution and kill switches are what you get; rate shaping is something you would still have to add.
What to do on Monday
You may never get WriteGuard, and the pattern is portable. Five steps, in order.
- List every write-capable tool across your MCP servers from a saved tools/list result, applying the spec defaults shown below.
- Give each tool one of four tiers in a file under version control, and leave anything you cannot classify disabled.
- Disable the tools whose effects trigger pipelines, production deployments or bulk deletes, and keep a human click for those.
- Put the check at the gateway or server, before the handler, where the agent's operator cannot turn it off. Keep client prompts as an extra layer, never as the policy.
- Record server, tool, tier, outcome, user, client and duration for every call, including blocked ones, and read the blocked attempts weekly.
# tools-list.json is a saved tools/list result. The inventory question is
# "which tools can write, and which of those might be destructive?"
# MCP spec defaults: readOnlyHint is false and destructiveHint is true, so a
# tool with NO annotations must be read as a destructive write.
# Wrong: jq treats false as "missing", so an explicit destructiveHint of
# false is replaced by true. A harmless additive tool is reported dangerous.
jq -r '.result.tools[] | [.name, (.annotations.destructiveHint // true)] | @tsv' tools-list.json
# get_merge_request true
# create_mr_note true <- declared destructiveHint false, reported true
# merge_mr true
# Right: test for null explicitly and apply the spec defaults.
jq -r '.result.tools[] | .annotations as $a
| [.name, ($a.readOnlyHint // false),
(if $a.destructiveHint == null then true else $a.destructiveHint end)]
| @tsv' tools-list.json
# get_merge_request true true (read-only; destructive is meaningless)
# create_mr_note false false (additive write)
# merge_mr false true (no annotations: assume destructive)If you are a Cloudflare customer, ask for private-beta access and use the three open questions from section 7 as your validation list. If not, the sketch above is about fifty lines, but it has no tests, no rate cap and a naive scrubber, so treat it as a starting point and not as production code.
The rule I take from this: authorization belongs where the caller cannot switch it off. A prompt asks, and a server-side tier table decides. Keep both if you like, but write the policy only in the second, default to deny, and make every write say whose agent made it.
Sources and further reading
- Cloudflare blog: WriteGuard, fine-grained controls for MCP servers (5 August 2026)
- InfoQ: Cloudflare WriteGuard adds fine-grained security controls to MCP servers
- Cloudflare docs: MCP server portals
- MCP specification 2025-06-18: Tools
- MCP specification 2025-06-18: Elicitation
- MCP schema.ts 2025-06-18 on GitHub: ToolAnnotations

