I was going through the tool list in server.py, the MCP server I built to run my GitHub profile and dev.to publishing from inside an agent session, and I stopped on this one:
@mcp.tool()
def update_article(article_id: int, title: str = None, body_markdown: str = None, published: bool = None) -> dict:
"""Update an existing DEV.to article by id."""
article = {}
if title is not None:
article["title"] = title
if body_markdown is not None:
article["body_markdown"] = body_markdown
if published is not None:
article["published"] = published
result = _dev(f"/articles/{article_id}", method="PUT", data={"article": article})
return {"id": result["id"], "url": result.get("url"), "published": result.get("published")}
Six lines of body, one PUT request, and it will happily rewrite the title, body, or publish state of any article the API key can touch — which, since dev.to scopes an API key to one account, means every article I've ever published. All it needs is an integer.
I keep seeing a version of this same story trend on dev.to lately: someone gives an agent a tool, the tool's permission check technically passes, and the outcome is still bad, because "the call succeeded" was never the thing that needed checking. Usually the write-ups are about account takeovers or cloud resource deletion. Mine is smaller and much less dramatic, but it's the same shape, and I found it in code I wrote myself, not in a demo.
why this one is different from "give an agent a destructive tool"
The obvious version of this problem is "don't give an agent rm -rf without asking." That's not what's happening here. update_article isn't reckless on purpose — I wrote it to let an agent fix a typo in a live post without me re-pasting the whole article. That's a legitimate, low-risk-sounding use case. The problem is what's missing from the legitimate path, not some obviously dangerous one.
Three things are absent:
-
No confirmation of identity. The tool takes
article_id: intwith no name, no title echo, nothing to sanity-check against. If an agent is holding two numbers in context — an article ID and, say, a GitHub issue number, or last week's article ID instead of this week's — there is nothing in the tool's contract that would catch the swap. It just PUTs. - No diff. The old title, body, and publish state are never fetched or compared. The caller finds out what changed by... not finding out. The response only echoes back the new state.
- No log. Nothing records that a write happened at all. If a live article's title changed and I noticed a week later, I would have zero trail connecting it to this tool, this run, or this article ID.
None of these are exotic failure modes. They're the same three things you'd want on any endpoint that does a blind overwrite: confirm the target, show the diff, log the write. I just hadn't put them on this one, because when I wrote it the mental model was "small helper for me," not "tool an agent calls autonomously across dozens of scheduled runs a day."
the actual risk, stated plainly
This server runs unattended, twice a day, publishing new articles and occasionally touching old ones. If a future version of this pipeline ever calls update_article with a hallucinated or stale ID — easy to imagine if an agent is juggling "the article I just published" against "an article I read about in the trending list" in the same context window — it silently overwrites whatever that ID actually points to. A live, previously-published post gets its title or body replaced, with no error, no confirmation, and (until now) no record.
It's not a security hole in the "someone else can edit my content" sense — the dev.to API already scopes writes to the key's own account, so an outside attacker can't ride this in. It's a blast-radius problem: the one actor who can call this tool (my own agent) has no guardrail between "correct call" and "correct-looking call that happens to be wrong," and the two look identical from inside the function.
the fix
I didn't want to add a human-in-the-loop confirmation step — the whole point of this server is that it runs unattended. So the fix isn't "ask before writing," it's "make the write leave evidence, and make the caller able to see the diff without a second round trip":
_ARTICLE_UPDATE_LOG = "logs/article_updates.jsonl"
def _log_article_update(article_id, before, fields_changed, after):
os.makedirs(os.path.dirname(_ARTICLE_UPDATE_LOG), exist_ok=True)
entry = {
"article_id": article_id,
"fields_changed": sorted(fields_changed),
"title_before": before.get("title"),
"title_after": after.get("title"),
"published_before": before.get("published"),
"published_after": after.get("published"),
"url": after.get("url"),
}
with open(_ARTICLE_UPDATE_LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
@mcp.tool()
def update_article(article_id: int, title: str = None, body_markdown: str = None, published: bool = None) -> dict:
"""Update an existing DEV.to article by id. Fetches the article's current
state first so the diff is known and logged before the write lands."""
before = _dev(f"/articles/{article_id}")
article = {}
if title is not None:
article["title"] = title
if body_markdown is not None:
article["body_markdown"] = body_markdown
if published is not None:
article["published"] = published
result = _dev(f"/articles/{article_id}", method="PUT", data={"article": article})
_log_article_update(article_id, before, article.keys(), result)
return {
"id": result["id"],
"url": result.get("url"),
"published": result.get("published"),
"diff": {
"title": {"before": before.get("title"), "after": result.get("title")},
"published": {"before": before.get("published"), "after": result.get("published")},
},
}
Two changes, both cheap:
- Fetch before write. One extra GET, same article ID, before the PUT. Now the "before" state exists in memory at the moment of the write, not just in whatever the agent remembers from three tool calls ago.
-
Return the diff, log the write. The tool's return value now tells the caller exactly what moved — if
title.beforeisn't what the agent expected, that's the signal something pointed at the wrong ID. And every write lands a line inlogs/article_updates.jsonl, independent of whether anyone's watching the response in the moment.
I unit-tested the diff/logging function directly against fake before/after states (no live API call — I wasn't going to test this by actually mangling one of my own published articles) and confirmed the JSONL entry and diff both carry the right before/after pairs. The GET-then-PUT sequencing costs one extra request per update, which is negligible next to what a silent wrong-ID overwrite costs.
the part I'd generalize
The lesson isn't "add logging to everything." It's narrower: any tool where the input is a bare ID and the output is a full overwrite is a spot worth a second look, specifically for whether the correct-looking call and the wrong call are distinguishable from inside the function. create_article doesn't have this problem — it can only add, never silently replace something that already existed, so a bad call just makes an extra draft, which is cheap to notice and delete. update_article was the one tool in this server where a plausible-looking call and a wrong one produced identical code paths. That's the pattern to grep for, not "does this tool sound dangerous."
Top comments (0)