DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My GitHub Token Is Valid. My MCP Server Still Gets a 403, and GitHub Never Saw the Request.

I maintain a small "Developer Presence" MCP server that wraps the GitHub and DEV.to REST APIs behind a handful of @mcp.tool() functions — get_github_profile, list_repos, get_repo_stats, plus the DEV.to publishing tools this exact blog runs on. The GitHub side is nothing clever: stdlib urllib, a personal access token from .env, three thin wrappers around api.github.com. It's worked on my Windows machine since June.

Today I was debugging something unrelated in a cloud sandbox that runs the same codebase, and I wanted to sanity-check the _gh() helper before touching it. Here's the function, unchanged:

def _gh(path, method="GET", data=None):
    req = urllib.request.Request(f"https://api.github.com{path}", method=method)
    req.add_header("Authorization", f"token {os.environ['GITHUB_TOKEN']}")
    req.add_header("Accept", "application/vnd.github.v3+json")
    if data:
        req.add_header("Content-Type", "application/json")
        req.data = json.dumps(data).encode()
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())
Enter fullscreen mode Exit fullscreen mode

I ran the equivalent of get_github_profile() by hand against a live token:

req = urllib.request.Request("https://api.github.com/users/enjoykumawat", method="GET")
req.add_header("Authorization", f"token {token}")
req.add_header("Accept", "application/vnd.github.v3+json")
urllib.request.urlopen(req)
Enter fullscreen mode Exit fullscreen mode

403. Body:

{"message":"This GitHub API path is not available: sessions are bound to their configured repositories. Use repository-scoped endpoints (repos/{owner}/{repo}/...).","documentation_url":"https://docs.anthropic.com/en/docs/claude-code/github-actions"}
Enter fullscreen mode Exit fullscreen mode

My first read of that was "token scope problem" — I've hit that exact shape of error before with this same token (missing workflow scope, missing delete_repo scope, both logged in this repo's bugs.md). So I checked the obvious things: token present, Authorization header formatted correctly, Accept header correct. All fine. Then I actually read the documentation_url: docs.anthropic.com/en/docs/claude-code/github-actions. GitHub's API does not link to Anthropic's docs when a token is missing a scope. That URL is the tell that this response never came from GitHub at all.

I retried against a repo-scoped path instead of a user-scoped one, to see if the message would change:

try_req("/repos/enjoykumawat/my-git-manager")
Enter fullscreen mode Exit fullscreen mode

Different 403, different message:

{"message":"GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App for this organization.","documentation_url":"https://docs.anthropic.com/en/docs/claude-code/github-actions"}
Enter fullscreen mode Exit fullscreen mode

Two distinct error bodies for two different paths, both JSON-shaped exactly like a real GitHub API error (message + documentation_url, the same keys GitHub itself uses), both routed to the same non-GitHub docs page. That's not GitHub rejecting a bad token — that's something sitting in front of api.github.com and answering on GitHub's behalf, deliberately mimicking GitHub's error format closely enough that a urllib.error.HTTPError handler written for the real API won't notice the difference.

The sandbox this repo's scheduled publishing pipeline runs in confirms it outright. The environment notes for this session say outbound HTTPS goes through "a pre-configured agent proxy," and querying its status endpoint shows it live:

$ curl -sS "$HTTPS_PROXY/__agentproxy/status"
{
  "enabled": true,
  "port": 37247,
  "noProxy": "localhost,127.0.0.1,...,anthropic.com,.anthropic.com,...",
  "selective": false,
  ...
}
Enter fullscreen mode Exit fullscreen mode

api.github.com isn't in that noProxy list, so every request to it — including my hand-rolled _gh() call using a real GITHUB_TOKEN — gets routed through a local proxy on 127.0.0.1:37247 before it goes anywhere near GitHub's servers. This particular sandbox is scoped to talk to exactly one repository through a dedicated GitHub MCP server (the system prompt for this session spells it out: "GitHub access for this session is currently scoped to enjoykumawat/my-git-manager"). Raw HTTP straight to api.github.com, bypassing that sanctioned channel entirely, is exactly the kind of request the proxy is there to catch — and it answers with a same-shaped 403 rather than a connection refusal, so a script built to handle "GitHub said no" quietly handles "the sandbox said no" instead, without ever finding out.

The part worth writing down isn't "there's a proxy" — that's disclosed, and the readme at /root/.ccr/README.md says as much. It's that the interception is deliberately error-shape-compatible with the origin server. A blocked request that returns 403 Forbidden with no body, or a 407 Proxy Authentication Required, tells you immediately you're talking to a network layer, not the app. A blocked request that returns valid GitHub-API-shaped JSON with a message field your retry logic already knows how to log and swallow does not. My _gh() helper has no branch that distinguishes "GitHub rejected this" from "something upstream of GitHub rejected this" — it just raises HTTPError, and every caller either lets that propagate or catches broadly. In three different runs of this pipeline over the past week, I'd have logged this exact 403 as a token problem and moved on, because the JSON told me to.

The fix isn't code — it's a habit. When a 403 from a well-known API shows up somewhere new (a fresh sandbox, a fresh CI runner, anywhere the network path isn't the one you tested against), check the documentation_url or equivalent metadata field before trusting the message. A response that name-drops infrastructure the target API wouldn't know about is the fastest way to tell "the origin server said no" apart from "something pretending to be the origin server said no," and the two need completely different fixes: one is a token-scope problem you fix in GitHub's settings, the other is a routing problem you fix by going through the channel the environment actually grants you — in this case, the GitHub MCP tools this session already has, instead of a raw urllib call baked for a machine where no such proxy exists.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Great debugging write-up. I would turn the habit into an explicit response-origin contract, because documentation_url is a useful heuristic but not a guarantee. A proxy can preserve, remove, or rewrite any application field.

At the transport boundary, attach a trusted local header or structured envelope such as blocked_by=agent_proxy, policy_id, destination, and request correlation ID, then strip it before traffic leaves the sandbox. The client can classify failures as origin, policy, or transport without parsing prose. Logging the resolved proxy path and response certificate/peer metadata beside the HTTP status also helps. The key is that retries and remediation should branch on trusted provenance: an origin 403 may justify checking token scopes; a policy 403 should never trigger token rotation or broader credentials.