cd /news/developer-tools/my-mcp-server-s-github-token-can-wri… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-91443] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

My MCP Server's GitHub Token Can Write. The Code That Promises It Never Will Had No Test.

A developer's MCP server for GitHub and DEV.to has a read-only guard in its `_gh()` helper that blocks non-GET requests, but the `--selftest` suite lacks a regression test for this guard, meaning a future edit could silently enable write access with a token scoped for full repo control. The developer reproduced the issue in isolation and plans to add a test.

read5 min views1 publishedAug 11, 2026

My MCP server (developer-presence

, the one that lets Claude check my GitHub profile and manage my DEV.to posts) has exactly three GitHub tools: get_github_profile

, list_repos

, get_repo_stats

. All three read data. None of them create, update, or delete anything.

The GITHUB_TOKEN

behind them isn't scoped that way. My own key_facts.md

says it plainly:

Token scopes needed: `repo`, `user` β€” add `delete_repo` if repo deletion
via API is required, add `workflow` if you'll ever push a branch that
pulls in upstream `.github/workflows/*.yml` changes

repo

is GitHub's full-control scope β€” it can push commits, edit files, change repo settings, everything short of an outright delete. I gave it that scope because other parts of this project (the git-writing side, not the MCP server) need it. The MCP server just inherits the same .env

file and, with it, the same token.

So every call this server's GitHub helper makes is running with write credentials it never intends to use. The only thing standing between "never intends to" and "actually can't" is one function.

def _gh(path, method="GET", data=None):
    if method != "GET":
        raise ValueError(f"_gh is read-only β€” got method={method!r}")
    if data is not None:
        raise ValueError("_gh is read-only β€” got a data payload on a GET call")
    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        raise RuntimeError("GITHUB_TOKEN not set β€” add it to .env next to server.py")
    req = urllib.request.Request(f"https://api.github.com{path}", method=method)
    req.add_header("Authorization", f"token {token}")
    ...

Every one of the three GitHub tools routes through _gh()

, and none of them ever pass method=

or data=

. That's the entire enforcement: a single if

that turns "this file happens to only call GET" into "this file cannot call anything but GET." I added that guard back on 2026-07-30 specifically so a future tool β€” create_repo

, star_repo

, whatever gets bolted on next β€” can't silently start using write access this token has but this server was never supposed to touch.

The comment even calls it out: "Enforced, not just true by convention." I wrote that line myself, seven days ago, and I believed it.

server.py

has a --selftest

block that's grown steadily since late July β€” every bug fixed in this file gets a regression case in the same run, so it can't quietly come back. It currently covers: the attribution-stripping regex, list_repos

's negative-limit handling, create_article

's pagination walk, and both _gh()

/_dev()

's missing-credential path.

It does not cover the read-only guard. I went looking for it assuming it'd be there β€” it's the security-relevant one, the one whose whole job is standing between a scoped-for-write token and an actual write β€” and it wasn't. Nothing in this file ever calls _gh(path, method="POST")

and asserts it blows up.

That means the promise in the comment was never actually checked by anything except me reading the four lines below it. Delete the if method != "GET":

check in a future edit β€” a merge conflict resolved wrong, a "just add a quick write tool" PR that forgets the guard exists β€” and --selftest

would still print selftest ok

. The regression would only surface the first time some caller passed a non-GET method and it actually went through to GitHub.

I checked this wasn't hypothetical by reproducing it in isolation, without hitting the network:

try:
    _gh("/users/x", method="POST")
    print("no exception raised")   # this is what a missing guard looks like
except ValueError as e:
    print("guard fired:", e)

With the guard in place: guard fired: _gh is read-only β€” got method='POST'

. Comment that one if

out locally and rerun it, and you get no exception raised

β€” the request would have gone out with Authorization: token <full-scope-token>

attached to a POST.

Two assertions, next to the other credential-path tests in the same selftest block:

try:
    _gh("/users/x", method="POST")
    assert False, "_gh must reject a non-GET method, not silently send it"
except ValueError as e:
    assert "read-only" in str(e), e

try:
    _gh("/users/x", data={"a": 1})
    assert False, "_gh must reject a data payload, not silently attach it to a GET"
except ValueError as e:
    assert "read-only" in str(e), e

Ran the full block afterward (stubbing the mcp

package import, since this sandbox can't install it cleanly against the system's PyJWT β€” a separate annoyance): selftest ok

, all existing cases still pass, plus these two new ones actually exercise the line the comment was vouching for.

I've written a few posts from this project about missing except

clauses and missing pagination β€” plain correctness bugs. This one's different in kind. The guard was correct the entire time; nothing was broken. What was missing was proof that it stays correct. A least-privilege enforcement with a comment claiming it's "enforced, not just true by convention" and zero lines of test coverage is, in practice, exactly the "true by convention" thing the comment says it isn't β€” it just has better PR.

If your MCP server (or any tool-calling code) holds a credential scoped wider than the code path actually needs β€” and check key_facts.md

-equivalent for your own project, because mine had been sitting there in plain English for weeks β€” the code that enforces the narrower behavior deserves the same test discipline as the code that implements the feature. A guard nobody can break without --selftest

noticing is a guard. A guard that only a human rereading four lines can vouch for is a comment.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @github 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/my-mcp-server-s-gith…] indexed:0 read:5min 2026-08-11 Β· β€”