DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

I Fixed My MCP Tool to Diff Before Overwriting an Article. The Diff Never Looked at the Body.

Two days ago I fixed a real problem: my update_article MCP tool took a bare article_id: int, PUT whatever fields were given straight to DEV.to's API, and had no fetch-before-write, no diff, and no log — a wrong or hallucinated id would silently overwrite a live published post with zero trace. The fix fetched the article's current state before writing, logged every write to a JSONL audit file, and returned a diff field in the response. I called it done and moved on.

It wasn't done. The diff I shipped only ever looked at two fields — title and published — no matter which field the caller actually changed. The one field this tool exists to edit, body_markdown, was never in it.

What the "fixed" code actually did

@mcp.tool()
def update_article(article_id: int, title: str = None, body_markdown: str = None, published: bool = None) -> dict:
    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")},
        },
    }
Enter fullscreen mode Exit fullscreen mode

Look at the diff dict. It's hardcoded to two keys, unconditionally, regardless of what article actually contains. Call this with only body_markdown set — the single most common real use of this tool, rewriting a post's content — and the returned diff still dutifully reports title and published, both unchanged, because they were never touched. Nothing in the response says the body changed at all.

The log function has the same blind spot, one level deeper:

def _log_article_update(article_id, before, fields_changed, after):
    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")
Enter fullscreen mode Exit fullscreen mode

fields_changed does correctly say ["body_markdown"] — that part I got right. But the entry logged alongside it never captures what the body actually was, before or after. You'd know a body edit happened. You'd have no way to see what changed, or recover the old text, from the log this tool writes about its own writes.

Proving it without touching a live article

Same caution as two days ago — I didn't want to verify this by actually mutating a real published post, so I stubbed _dev's before/after states and ran the exact logic:

before = {"id": 42, "title": "How I Debug Timeout Bugs", "published": True,
          "body_markdown": "old body text about timeouts...", "url": "https://dev.to/x/42"}
article = {"body_markdown": "ENTIRELY REWRITTEN BODY, wrong article, hallucinated content"}
result = dict(before, **article)

diff = {
    "title": {"before": before.get("title"), "after": result.get("title")},
    "published": {"before": before.get("published"), "after": result.get("published")},
}
Enter fullscreen mode Exit fullscreen mode
{
  "title": {"before": "How I Debug Timeout Bugs", "after": "How I Debug Timeout Bugs"},
  "published": {"before": true, "after": true}
}
Enter fullscreen mode Exit fullscreen mode

That's the entire diff a caller sees after completely replacing the body of a live article. Title identical. Published identical. Nothing else reported. If a wrong article_id or a hallucinated body ever slips through — which is exactly the scenario two days ago's fix was written to catch — the fix's own diff would show a clean, boring, all-unchanged result while the actual content underneath it was gone.

Why this got past me the first time

I think I fell for the same trap the fix was supposed to prevent: I built a diff that looked complete because it had a diff key with two sub-objects in it, and moved on without asking whether those two fields were the ones actually at risk. title and published are cheap to eyeball and rarely wrong. body_markdown is the field a caller — human or agent — is most likely to get wrong, and it's arbitrarily long free text, which is probably why I reached for the two short, structured fields instead: they're easy to put in a dict literal. The field that actually needed diffing was the one that didn't fit that shape neatly, so I left it out without noticing I'd left out the point of the fix.

The actual fix

Build the diff from whatever was in article, not from a fixed pair:

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": {
        field: {"before": before.get(field), "after": result.get(field)}
        for field in article
    },
}
Enter fullscreen mode Exit fullscreen mode

And the log now only records before/after for the fields that were actually part of this write, whatever they are:

def _log_article_update(article_id, before, fields_changed, after):
    entry = {"article_id": article_id, "fields_changed": sorted(fields_changed), "url": after.get("url")}
    for field in fields_changed:
        entry[f"{field}_before"] = before.get(field)
        entry[f"{field}_after"] = after.get(field)
    with open(_ARTICLE_UPDATE_LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")
Enter fullscreen mode Exit fullscreen mode

Reran the same stubbed scenario against the fixed code: the diff now shows body_markdown.before as the old text and body_markdown.after as the new one, and the log entry carries both. A body-only write no longer produces a report that says nothing changed.

The lesson isn't "add a diff." I already did that, two days ago, and it still hid the exact failure it was built to surface. The lesson is that a diff is only as good as its coverage of the fields a real call is likely to touch — and the easiest way to miss that is building the diff's shape around what's convenient to write down, instead of around what the caller actually passed in.

Top comments (0)