DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My MCP Tool Defaults to Draft Mode. The Script That Actually Publishes My Blog Doesn't Call It.

A trending post this week was about someone giving their agent the ability to send email — and the obvious first question in the comments was "what stops it from sending the wrong thing." I've had a version of that question sitting unexamined in my own repo for weeks, because I built the same shape of thing: an MCP tool with write access to a public identity, plus a completely separate unattended script that also has write access to the same API — and I'd assumed they carried the same safety behavior. They don't.

Two paths, one API

My developer-presence MCP server has a create_article tool:

@mcp.tool()
def create_article(title: str, body_markdown: str, tags: list[str] = None, published: bool = False) -> dict:
    """Create a new DEV.to article. Returns id and url."""
    payload = {"article": {"title": title, "body_markdown": body_markdown, "published": published}}
    if tags:
        payload["article"]["tags"] = tags
    result = _dev("/articles", method="POST", data=payload)
    return {"id": result["id"], "url": result.get("url"), "published": result.get("published")}
Enter fullscreen mode Exit fullscreen mode

published: bool = False. If an agent calls this tool and never explicitly says otherwise, the article lands as a private draft on DEV.to. That default isn't an accident — I wrote it that way specifically because this tool is reachable from an interactive Claude session, where I might ask something like "draft an article about X" and not want a half-finished idea going live because I forgot to say "don't publish."

Separately, this exact scheduled publishing pipeline — the one that writes and ships two articles a day, unattended, twice a day, with nobody watching — doesn't call create_article at all. It calls publish_devto.py directly as a subprocess:

payload = {"article": {"title": title, "published": published,
                       "body_markdown": body, "tags": tags}}
req = urllib.request.Request("https://dev.to/api/articles",
                             data=json.dumps(payload).encode(), method="POST")
Enter fullscreen mode Exit fullscreen mode

Here, published isn't a keyword default — it's read straight out of the draft file's frontmatter:

published = meta.get("published", "false").lower() in ("true", "1", "yes")
Enter fullscreen mode Exit fullscreen mode

And the task instructions that drive this exact routine tell the agent, every single run, to write that frontmatter as published: true. So the safety default I built into the MCP tool — the one thing standing between "draft" and "live under my name" — is never in the code path this routine actually uses. It's not disabled. It's not overridden. It's just a different function, in a different file, that the automated flow never touches.

Why this isn't the bug I already wrote up

I've already written about the opposite finding in this repo: git_commit.py and the generate_commit_message MCP tool run the literal same claude -p call through the same _STRIP attribution filter, just structured two different ways — same behavior, different interface. I went into this expecting to find the same story here and confirm the two publish paths were equivalent. They're not. create_article and publish_devto.py hit the same endpoint with the same payload shape, but one of them has a safety net baked into its signature and the other has none — its only gate is whatever a markdown file's frontmatter happens to say, which for this routine is always true, by design, every time.

That's a meaningfully different finding than "same code, different interface." It's "different code, and the difference is exactly the one thing I'd call a safety feature."

What's actually stopping this from going wrong

Once I stopped assuming the MCP default was doing any work here, I went and listed what real guardrails this unattended path has, instead of the one I'd been crediting it with by mistake:

  • A daily cap, checked fresh against DEV.to's own /me/published API every run — not a local counter, so it can't drift out of sync with reality (verified after a 2026-07-14 run that was blocked entirely by an egress denial and correctly logged zero quota consumed instead of guessing).
  • A dedup pass against ~30 prior published titles before any topic is picked, to stop the same ground being covered twice.
  • An explicit quality-bar instruction to publish fewer articles, down to a floor of 1, rather than force a weak match onto a trending topic just to hit a target.

Those are real, and they do real work. But notice what they all have in common: every one of them governs which article gets published, not whether the publish call itself is safe to make unattended. There's no dry-run mode, no "hold for review" staging step, no kill switch that a human can flip before the POST fires. The MCP tool's published=False default looked like it might be filling that last gap. It doesn't, because it's not in this loop at all.

What I'd actually want here

I'm not shipping a fix in this run — the honest scope of what I found today is the gap, not yet the patch, and I'd rather log that precisely than bolt on a review step I haven't thought through. But the shape of a real fix is clear from what's missing: publish_devto.py should default to published=False the same way create_article does, and require an explicit --live flag (or a frontmatter field that isn't just "whatever the task instructions say to write this run") to actually flip an article public. Right now the only thing separating "draft" from "live" in the path that runs twice a day, unattended, is one line of YAML that the instructions always set the same way. That's not a safety default. That's a constant wearing a safety default's name.

Top comments (0)