cd /news/ai-agents/the-permission-boundary-my-mcp-serve… · home topics ai-agents article
[ARTICLE · art-98449] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

The Permission Boundary My MCP Server Doesn't Actually Have

A developer discovered that the safety gate in their MCP server's update_article tool for DEV.to was not a true permission boundary, as the confirm and expected_fingerprint parameters were optional and could be bypassed by omission. The developer fixed the issue by making the fingerprint required for live content writes, ensuring agents cannot skip the check.

read5 min views1 publishedAug 16, 2026

There's a theme showing up a lot in agent-tooling posts this week: agents holding tools they can misuse, and builders wiring some kind of gate in front of the dangerous ones — a signed capability, a policy layer, a human-in-the-loop check before the write actually lands. I built something that looks like that gate a while ago. Then I went and read my own code closely enough to notice it isn't one.

The tool in question is update_article

in the MCP server I run for my DEV.to publishing pipeline. It edits a live article by id — title, body, published flag. Overwriting a published article's content is the dangerous case: DEV.to keeps no version history, so a bad write is gone the moment it lands. Back in July I added a gate for exactly that:

live_content_write = before.get("published") and ("title" in article or "body_markdown" in article)
...
if live_content_write and not confirm:
    return {
        "id": article_id,
        "url": before.get("url"),
        "applied": False,
        "reason": "article is currently published — title/body_markdown changes "
                  "require confirm=True (DEV.to has no version history to undo this)",
        ...
    }

Call it without confirm=True

against a live article and you get the proposed diff back, not a write. That felt like a permission boundary. It is not one, and the reason took me embarrassingly long to see: confirm

is a keyword argument the caller supplies. There is no code path that forces an agent through the preview step before it can set confirm=True

. An agent that has never seen the docstring, or that decided on its own the article needed fixing right now, can call update_article(id, body_markdown=new_body, confirm=True)

as its very first move and the gate simply isn't there for that call. It only stops the caller who was already going to stop and ask.

I added a second layer a few weeks later, once I noticed the first one had its own gap: confirm=True

alone doesn't prove the diff you approved still describes the live article. Someone could edit it on-site between your preview and your confirm. So update_article

now also accepts expected_fingerprint

, a hash of the article's state at preview time, and refuses a stale write even with confirm=True

:

if live_content_write and expected_fingerprint is not None and expected_fingerprint != fingerprint:
    return {
        "id": article_id,
        "applied": False,
        "reason": "stale — the live article changed since the diff you approved was "
                  "generated (expected_fingerprint doesn't match the current article); "
                  "re-preview and re-approve against the current content before writing",
        "fingerprint": fingerprint,
        ...
    }

This is a genuinely better check — it catches drift the first version couldn't. But it has the exact same shape of hole as confirm

did: expected_fingerprint

is None

by default, and skipping it skips the check, not the write. Pass confirm=True

with no fingerprint and the fix I shipped for staleness never runs. My own docstring says this outright: "this is opt-in, not mandatory." I wrote that sentence as a note to a future reader. I should have read it as a bug report against the design.

The pattern underneath both gaps is the same one keyword-argument safety checks always have: a check that fires only when the caller supplies the evidence for it is a check the caller can opt out of by omission, not just by an explicit bypass flag. confirm=False

isn't the risky path — the risky path is a caller that never learned the parameter exists, and Python doesn't make that caller pass anything at all. Compare that to a boundary that can't be skipped by silence: the fingerprint could instead be required whenever live_content_write

is true, full stop, no default. A caller with no fingerprint gets refused, not waved through. That's one line — expected_fingerprint: str

instead of expected_fingerprint: str = None

, plus dropping the is not None

from the condition — and it turns "protects the caller who already knew to protect themselves" into "protects the article regardless of what the caller knew."

I haven't shipped that change yet, and I want to be honest about why: this specific tool is only ever invoked by me, through Claude Desktop, on my own machine. The realistic threat model for update_article

right now is my own mistake or a confused agent turn, not an adversarial caller — which is exactly the case optional confirmation still mostly protects against, since I'm the one who wrote the docstring and mostly remember to preview first. But "mostly remember" is precisely the property a permission boundary isn't supposed to depend on, and the whole reason I built the gate in the first place was that I didn't trust myself or an agent to remember reliably. An optional gate that only helps when you remember to use it optionally has quietly demoted itself to a linting suggestion.

The generalizable version of this, if you're building any MCP tool with a confirm

or dry_run

or force

parameter: ask what happens on the call that never mentions that parameter at all, not just the call that sets it to False

. If the answer is "the check that's supposed to run doesn't run, silently, with no error," you don't have a permission boundary — you have a boundary-shaped piece of documentation that only binds the callers who were never going to test it. The fix isn't more documentation telling the caller to remember. It's making the unsafe path fail loudly by default, so the only way to skip the check is to say so explicitly, in the code, where you can grep for it later. I'm making that change to update_article

this week and writing the --selftest

case first, so "required, not opt-in" is the behavior a future edit can't quietly walk back.

── more in #ai-agents 4 stories · sorted by recency
── more on @dev.to 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-permission-bound…] indexed:0 read:5min 2026-08-16 ·