I run a small MCP server (server.py in this repo) that already talks to the DEV.to API to publish articles — create_article POSTs to /api/articles with my key, works every time. So when I found, almost by accident, that 35 published articles had accumulated 20 unanswered comments (some six weeks old), the obvious next step looked like the same pattern: write a script, POST to some comments endpoint, done. I'd already automated writes to this API. Replying is just another write.
It isn't. And the way I found out told me more about how DEV.to's API is actually designed than any docs page did.
Two different failures, two different meanings
First attempt: POST /api/comments with a valid api-key header and a JSON body matching every field the articles endpoint expects, just pointed at comments instead. Response: 404.
Second attempt, thinking maybe reactions were the easier win (heart a comment instead of replying to it): POST /api/reactions with the same valid key, plus the Accept: application/vnd.forem.api-v1+json header the Forem API docs mention for some endpoints. Response: 401, even though the key demonstrably works for reading and for article creation.
Those aren't the same kind of "no." A 404 on a write endpoint usually means the endpoint doesn't exist for this action at all — there's no route to even evaluate your credentials against. A 401 means the endpoint exists, evaluated your key, and rejected it specifically for this operation. I'd effectively gotten two independent confirmations, from two different failure modes, that this wasn't a bug or a missing header — DEV.to's key-based API is deliberately narrower for comments and reactions than it is for articles, and the two endpoints fail differently because they're blocked at different layers (routing vs. authorization).
That's worth sitting with for a second, because it's not the obvious way to design a public API. Article creation is the bigger action — new public content, indexed, SEO-visible, can carry a byline. Commenting and reacting look smaller. But look at it from an abuse-surface angle instead of a size-of-blast-radius angle: article creation on DEV.to already comes with real friction (it's slow to fake at volume, and every article is individually attributable and moderatable as a first-class piece of content the platform already reviews). Comments and reactions are the opposite — they're the fast, cheap, high-volume unit of engagement-farming and algorithmic gaming across basically every content platform, and normal users don't have any legitimate workflow that needs to script them at scale. Restricting comments/reactions to session-based, human-driven interaction while leaving article creation open to key-based automation isn't an oversight. It's the same logic as rate-limiting login attempts far more aggressively than read queries: the tighter control goes exactly where the abuse economics are best, not where the action looks biggest.
Designing around a wall you can't request your way past
With a GitHub-style API, this problem usually has a normal answer: request a broader OAuth scope, or use a fine-grained token, and the platform trusts you because your account and the scope grant are both auditable. DEV.to's API has no such knob for comments/reactions — there's no scope to request that unlocks them for a personal API key, because the restriction isn't a missing permission, it's a design decision about what key-based automation is allowed to do on this platform at all, full stop.
So the fix wasn't "get the right credential." It was building a workflow that respects the boundary instead of trying to route around it:
def replied_by_me(comment):
return any(c["user"]["username"] == ME or replied_by_me(c)
for c in comment["children"])
def pending():
drafted = open(DRAFTS, encoding="utf-8").read()
out = []
for a in api(f"/articles?username={ME}&per_page=100"):
if not a["comments_count"]:
continue
for c in api(f"/comments?a_id={a['id']}"):
if c["user"]["username"] == ME or replied_by_me(c):
continue
if c["id_code"] in drafted:
continue
out.append({...})
return out
replied_by_me walks the comment's child replies recursively because a reply can be nested arbitrarily deep, and I only want comments where no reply anywhere in that thread came from me. The id_code in drafted check is a second, separate dedup layer against a local markdown file — because "have I replied on-site" and "have I already drafted a reply I just haven't pasted yet" are genuinely different states, and conflating them means either re-drafting replies I've already written, or silently never re-checking a comment because it looks handled when it isn't.
The script's entire job is to read (which the API allows freely) and hand me a clean, deduplicated list of what actually needs a human to open a link and paste text. It doesn't try to complete the loop; it does everything up to the exact point the platform draws the line, and stops there on purpose.
The transferable part
My last few posts about MCP were about hardening a server I control — validating inputs, capping tool-schema size, vetting what I install. This one's the mirror case: probing a black-box platform API you don't control, to find its actual write boundary empirically instead of assuming the docs list every restriction. Two things I'd tell past-me before starting that comment pipeline:
- Don't infer a platform's write permissions from one endpoint that works.
create_articlesucceeding told me nothing reliable about whether/api/commentswould. - When a write fails, the status code is data about why, not just whether. A 404 versus a 401 on the same kind of request point at two different mechanisms, and only one of them (401) is even theoretically a credentials problem — the other one means there's no door there to knock on.
The 20-comment backlog is still real work; a script can surface it, dedupe it, and hand me exactly what to paste, but it can't finish the last step, because DEV.to decided — correctly, once I thought about the abuse economics instead of my own convenience — that step needs a human account attached to it.
Top comments (2)
I like the boundary-respecting workflow. One HTTP nuance: a 404 proves that the requested representation is not exposed at that path, but not necessarily that no route exists; systems also use 404 to conceal resources or authorization state. Likewise, 401 means the request lacks acceptable authentication for that operation, while 403 is the usual “authenticated but not authorized” signal. I would treat the codes as strong hypotheses, then corroborate them with the OpenAPI spec, server source, or an official statement. For an MCP integration, a small startup capability matrix (method + endpoint + allowed/unsupported) also prevents one successful write endpoint from being generalized to every write.
This is a good example of an API boundary doing product work. Publishing a post and replying to a person are both writes, but they carry different abuse and identity risk. The frustrating wall is also a useful reminder: if an automation path is too easy, it may be missing the consent surface.