{"slug": "my-mcp-server-s-github-token-can-write-the-code-that-promises-it-never-will-had", "title": "My MCP Server's GitHub Token Can Write. The Code That Promises It Never Will Had No Test.", "summary": "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.", "body_md": "My MCP server (`developer-presence`\n\n, the one that lets Claude check my GitHub profile and manage my DEV.to posts) has exactly three GitHub tools: `get_github_profile`\n\n, `list_repos`\n\n, `get_repo_stats`\n\n. All three read data. None of them create, update, or delete anything.\n\nThe `GITHUB_TOKEN`\n\nbehind them isn't scoped that way. My own `key_facts.md`\n\nsays it plainly:\n\n```\nToken scopes needed: `repo`, `user` — add `delete_repo` if repo deletion\nvia API is required, add `workflow` if you'll ever push a branch that\npulls in upstream `.github/workflows/*.yml` changes\n```\n\n`repo`\n\nis 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`\n\nfile and, with it, the same token.\n\nSo 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.\n\n``` python\ndef _gh(path, method=\"GET\", data=None):\n    # No GitHub tool in this server writes anything — GITHUB_TOKEN is scoped\n    # `repo, user` (full write access, see key_facts.md), so a stray\n    # method=\"POST\"/\"DELETE\" here would be a real write, not a hypothetical\n    # one. Enforced, not just true by convention. See bugs.md 2026-07-30.\n    if method != \"GET\":\n        raise ValueError(f\"_gh is read-only — got method={method!r}\")\n    if data is not None:\n        raise ValueError(\"_gh is read-only — got a data payload on a GET call\")\n    token = os.environ.get(\"GITHUB_TOKEN\")\n    if not token:\n        raise RuntimeError(\"GITHUB_TOKEN not set — add it to .env next to server.py\")\n    req = urllib.request.Request(f\"https://api.github.com{path}\", method=method)\n    req.add_header(\"Authorization\", f\"token {token}\")\n    ...\n```\n\nEvery one of the three GitHub tools routes through `_gh()`\n\n, and none of them ever pass `method=`\n\nor `data=`\n\n. That's the entire enforcement: a single `if`\n\nthat 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`\n\n, `star_repo`\n\n, whatever gets bolted on next — can't silently start using write access this token has but this server was never supposed to touch.\n\nThe comment even calls it out: *\"Enforced, not just true by convention.\"* I wrote that line myself, seven days ago, and I believed it.\n\n`server.py`\n\nhas a `--selftest`\n\nblock 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`\n\n's negative-limit handling, `create_article`\n\n's pagination walk, and both `_gh()`\n\n/`_dev()`\n\n's missing-credential path.\n\nIt 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\")`\n\nand asserts it blows up.\n\nThat 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\":`\n\ncheck in a future edit — a merge conflict resolved wrong, a \"just add a quick write tool\" PR that forgets the guard exists — and `--selftest`\n\nwould still print `selftest ok`\n\n. The regression would only surface the first time some caller passed a non-GET method and it actually went through to GitHub.\n\nI checked this wasn't hypothetical by reproducing it in isolation, without hitting the network:\n\n```\ntry:\n    _gh(\"/users/x\", method=\"POST\")\n    print(\"no exception raised\")   # this is what a missing guard looks like\nexcept ValueError as e:\n    print(\"guard fired:\", e)\n```\n\nWith the guard in place: `guard fired: _gh is read-only — got method='POST'`\n\n. Comment that one `if`\n\nout locally and rerun it, and you get `no exception raised`\n\n— the request would have gone out with `Authorization: token <full-scope-token>`\n\nattached to a POST.\n\nTwo assertions, next to the other credential-path tests in the same selftest block:\n\n```\ntry:\n    _gh(\"/users/x\", method=\"POST\")\n    assert False, \"_gh must reject a non-GET method, not silently send it\"\nexcept ValueError as e:\n    assert \"read-only\" in str(e), e\n\ntry:\n    _gh(\"/users/x\", data={\"a\": 1})\n    assert False, \"_gh must reject a data payload, not silently attach it to a GET\"\nexcept ValueError as e:\n    assert \"read-only\" in str(e), e\n```\n\nRan the full block afterward (stubbing the `mcp`\n\npackage import, since this sandbox can't install it cleanly against the system's PyJWT — a separate annoyance): `selftest ok`\n\n, all existing cases still pass, plus these two new ones actually exercise the line the comment was vouching for.\n\nI've written a few posts from this project about missing `except`\n\nclauses 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.\n\nIf your MCP server (or any tool-calling code) holds a credential scoped wider than the code path actually needs — and check `key_facts.md`\n\n-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`\n\nnoticing is a guard. A guard that only a human rereading four lines can vouch for is a comment.", "url": "https://wpnews.pro/news/my-mcp-server-s-github-token-can-write-the-code-that-promises-it-never-will-had", "canonical_source": "https://dev.to/enjoy_kumawat/my-mcp-servers-github-token-can-write-the-code-that-promises-it-never-will-had-no-test-j38", "published_at": "2026-08-11 03:37:19+00:00", "updated_at": "2026-08-11 04:19:34.729639+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["GitHub", "DEV.to", "Claude", "MCP"], "alternates": {"html": "https://wpnews.pro/news/my-mcp-server-s-github-token-can-write-the-code-that-promises-it-never-will-had", "markdown": "https://wpnews.pro/news/my-mcp-server-s-github-token-can-write-the-code-that-promises-it-never-will-had.md", "text": "https://wpnews.pro/news/my-mcp-server-s-github-token-can-write-the-code-that-promises-it-never-will-had.txt", "jsonld": "https://wpnews.pro/news/my-mcp-server-s-github-token-can-write-the-code-that-promises-it-never-will-had.jsonld"}}