The GitHub MCP server control model, explained
The GitHub MCP server is the benchmark for this series: first-party, ~30k stars, roughly 80 tools in 23 toolsets, default wiring in Copilot and VS Code, and the richest native control surface of any server we’ve surveyed. It’s also unusually honest documentation — GitHub says plainly which of its controls are boundaries and which are best-effort. This page is the reference: what ships, exactly how each mechanism behaves, and where the model ends. Sources: the official server configuration and remote server docs and the December 2025 changelog.
This page covers the server’s own controls. For the cross-server picture, see the MCP server controls comparison; for what a control layer adds on top, the coverage matrix.
What’s at stake on this surface
Worth being concrete, because this is the server people hand to coding agents on real repositories: the tool surface includes pull request creation and merge, multi-file pushes (push_files), issue and repository writes, workflow-run triggers, and Actions log access — the last being secrets-adjacent. An agent holding an unscoped token on an unfiltered surface can merge its own PR and trigger the deploy. Every control below exists to narrow that sentence.
Auth: the token is the floor
Local runs authenticate with a personal access token; the remote server (https://api.githubcopilot.com/mcp/) uses OAuth. Either way, the credential’s scope is the one control enforced on GitHub’s side of the wire — a fine-grained PAT restricted to specific repositories with no workflow scope holds regardless of anything in the client config. Start there: the token is the only line every other control sits above, and the only one the agent can’t edit its way past. Everything below runs in or before the server process — which, on a local install, runs on the same machine as the agent.
Read-only: real, strict, and three spellings
--read-only (flag), GITHUB_READ_ONLY=1 (env), or X-MCP-Readonly: true (remote header). The remote server also serves per-toolset read-only paths — /x/repos/readonly gives you the repos toolset with no writes. It’s a strict filter: write tools are removed from tools/list entirely rather than soft-blocked, and read-only takes precedence — a toolset or tool selection that would include a write loses that write. This is the strongest single switch the server has, and the right default for any agent that only needs to look.
Toolsets and per-tool selection: subtraction as policy
--toolsets repos,issues (or GITHUB_TOOLSETS) enables groups; since December 2025, --tools / GITHUB_TOOLS selects individual tools — the finest grain available. Remote equivalents: X-MCP-Toolsets and X-MCP-Tools headers. Unselected tools don’t exist on the surface, which is meaningfully better than existing-but-denied: the model never sees them, so it never plans around them.
The composition with read-only is clean — but notice what kind of control this is. It’s subtraction at launch time. You can remove merge_pull_request from the surface; you cannot express “merge only with a human’s yes,” “merge only on repos matching this pattern,” or “no writes within ten minutes of reading an issue authored by an outside contributor.” One decision, made once, before the session starts.
Dynamic toolsets: the boundary the model can move
GITHUB_DYNAMIC_TOOLSETS=1 starts the server minimal and exposes list_available_toolsets and enable_toolset — as tools. The model asks for more surface and receives it. This is a real answer to context-window economics (80 tool schemas are expensive), and it’s the clearest illustration on this page of who the native controls answer to: a restriction the governed model can lift by calling a tool is a suggestion with extra steps. If dynamic toolsets are on, whatever you were using toolset restriction to enforce needs to be enforced somewhere else.
Lockdown mode: a control with a disclaimer, and a CVE
--lockdown-mode / GITHUB_LOCKDOWN_MODE / X-MCP-Lockdown: true filters content authored by users without push access out of tool results — issue bodies and comments from outside contributors, the exact channel the private-repo leak came through. Two facts to hold together:
- GitHub says what it is not. The docs call lockdown a risk-reduction measure and state it is not an authorization boundary. That candor is rare and correct — content filtering reduces injection surface; it doesn’t decide what the agent may do.
- The control itself had a bug. GHSA-pjp5-fpmr-3349 (June 2026, moderate): in HTTP mode, the lockdown implementation’s singleton could confuse GraphQL clients across users. Patched — but it’s a concrete instance of a general rule this series keeps finding: in-process controls share the process’s failure modes. A control that lives inside the thing it guards goes down with it.
Audit: none at the server
The server writes no record of what an agent called. Token-level events land in GitHub’s own logs the way any API traffic does, but there is no MCP-level, operator-queryable trail — no record of which tools fired, with what arguments, under which read-only/toolset posture, and nothing that distinguishes the agent’s calls from any other use of the token. Whatever record you want of agent behavior on this surface, something other than the server has to write it.
Where the native model ends
Grade this server generously — we do; it’s the best row on the comparison table — and the shape of what’s missing is still visible:
- Every control is launch-time configuration, living in the client’s MCP config: the file in the workspace, frequently writable by the agent it configures, always writable by whoever — or whatever — sets up the connection. The one exception is token scope, enforced by GitHub.
- Nothing is per-call. Read-only, toolsets, tools, lockdown: all select the surface once. No native mechanism distinguishes one call to
push_filesfrom another, holds a merge for a human, or conditions a write on what was just read. - Nothing crosses servers. The leak incident was a GitHub-read followed by a GitHub-write, but the general class — read untrusted anywhere, act anywhere else — is invisible from inside any one server by construction.
- Nothing is written down. No approvals to record and no record regardless.
The practical posture, then: scope the token fine-grained (the floor), default to --read-only, add toolsets back deliberately, keep dynamic toolsets off for anything unattended, turn lockdown on — and put the per-call policy, the approval path, and the record in a layer the agent can’t edit. The server’s own docs, to their credit, never claim otherwise.