Control agent access
How to give an AI agent read-only GitHub access
An agent that reads your issues does not need to merge your pull requests. GitHub gives you two ways to take that power away, and a gateway gives you a third for the case the first two cannot cover.
By the end of this you will have GitHub's official MCP server running so that an agent can list and read issues and pull requests, and cannot merge, push, close or edit anything. You will also know which of the three controls to reach for when the agent needs to do one small write, like leaving a comment.
The longer argument for why this matters, including what happens when issue text talks an agent into a merge, is in Your triage agent can also merge pull requests. This tutorial is the practical half.
Prerequisites
- A GitHub account with access to the repositories the agent should read.
- Docker, to run the server locally. The steps below were checked against the
ghcr.io/github/github-mcp-serverimage, version 2.0.2. - An MCP client such as Claude Code, Cursor or VS Code. Adding a server to each one is covered in How to add MCP servers to Claude Code and How to set up MCP servers in Cursor.
Step 1: Create a read-only token
Start with the credential, because it is the one control GitHub enforces no matter which server or client you use. A token that cannot write cannot be talked into writing.
Create a fine-grained personal access token:
- Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens and click Generate new token.
- Give it a name that says which agent it is for, such as
triage-agent-read, and an expiration. - Under Repository access, choose Only select repositories and pick the repositories the agent should see. Nothing else.
- Under Repository permissions, set Contents, Issues and Pull requests to Read-only. Metadata is added automatically. Leave everything else at No access, and leave account permissions empty.
- Click Generate token and copy it. GitHub shows it once.
One token per agent is worth the extra minute. When you want to stop one agent, you revoke its token and every other agent keeps working.
Step 2: Run the server in read-only mode
The token stops GitHub from accepting writes. Read-only mode stops the server from offering write tools in the first place, so the agent never sees merge_pull_request in its tool list and never tries.
Locally, set GITHUB_READ_ONLY when you start the container:
docker run -i --rm \
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-read-only-token> \
-e GITHUB_READ_ONLY=1 \
ghcr.io/github/github-mcp-serverIf you run the binary directly, the flag is --read-only. You can also cut the tool list down to the areas the agent needs with toolsets. For a triage agent that is issues and pull requests:
docker run -i --rm \
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-read-only-token> \
-e GITHUB_READ_ONLY=1 \
-e GITHUB_TOOLSETS=issues,pull_requests \
ghcr.io/github/github-mcp-serverHere is what that does to the tool list. I started the image with the issues and pull_requests toolsets and asked it for its tools, with and without read-only mode:
| Mode | Tools offered |
|---|---|
| Normal | 20, including merge_pull_request, create_pull_request, issue_write and add_issue_comment |
| Read-only | 9: issue_read, list_issues, search_issues, list_pull_requests, pull_request_read, search_pull_requests, get_label, list_issue_types, list_issue_fields |
If you use GitHub's hosted server instead of running your own, the same switch is a header. Add X-MCP-Readonly: true to the request, or put /readonly on the end of the URL. This is the shape for a client that takes a URL and headers:
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer <your-read-only-token>",
"X-MCP-Toolsets": "issues,pull_requests",
"X-MCP-Readonly": "true"
}
}Step 3: Connect your client and check the tool list
Add the server to your client using the command from Step 2. In Claude Code, for example:
claude mcp add github-read \
-e GITHUB_PERSONAL_ACCESS_TOKEN=<your-read-only-token> \
-- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN -e GITHUB_READ_ONLY=1 \
-e GITHUB_TOOLSETS=issues,pull_requests ghcr.io/github/github-mcp-serverStart a new session, because clients read their servers once at startup. Then ask the agent what GitHub tools it has. You should see the nine read tools above and nothing that writes. Ask it to merge a pull request and it should tell you it has no tool for that.
At this point the job is done for a pure reader. The token cannot write, and the server does not offer anything that tries.
Step 4: Decide what to do when the agent needs one write
Most real agents need one small write. A triage agent reads an issue and then leaves a comment or a label. This is where the two GitHub controls stop fitting:
- Read-only mode removes the comment tool too.
add_issue_commentis a write, so it is one of the eleven tools read-only mode drops. - The token cannot tell a comment from a close. Commenting needs Issues: Read and write, and that same permission lets the token edit and close issues.
The server has one more option: an explicit tool list. Start it with GITHUB_TOOLS (or --tools) and it offers exactly those tools and no others:
docker run -i --rm \
-e GITHUB_PERSONAL_ACCESS_TOKEN=<token-with-issues-write> \
-e GITHUB_TOOLS=list_issues,issue_read,search_issues,add_issue_comment \
ghcr.io/github/github-mcp-serverI checked this one too: the server offers those four tools and nothing else. Two things to know about it. Read-only mode wins over the list, so combining them drops add_issue_comment again. And the list is fixed when the server starts, so it applies to every client connected to that process. If two agents need different tools, you run two copies of the server, each with its own token and its own list.
That is a fine setup for one agent on one machine. It gets heavy when you have several agents, several people and several servers, because every combination is another process and another copy of the token.
Step 5: Put the server behind a gateway with per-tool grants
A gateway moves the decision from the server's startup flags to each call. You run one GitHub server, and the gateway checks every call against who is making it. This is the approach Statio takes:
- Install GitHub from the catalog. It connects to GitHub's hosted MCP server, and you paste the token once under Credentials. It is stored encrypted and is never sent to the agent.
- Create the agent under Settings → Agents. It gets its own client ID and secret, separate from yours.
- On the server's permissions, grant the agent the tools it needs:
list_issues,issue_read,search_issuesandadd_issue_comment. Nothing is granted by default, so every other tool stays closed to it. - Connect the agent's client through Statio, as described in Connect a client.
The agent's tool list now shows only what it was granted. If it calls merge_pull_request anyway, the gateway refuses the call before it reaches GitHub, and the refusal is recorded in the audit log next to the calls that went through. Another agent can use the same server with a different set of grants, and you change a grant without restarting anything.
You still want the read-only token for agents that never write. The layers stack: the token limits what GitHub will accept, and the grants limit what each agent can ask for.
Conclusion
You now have three controls and a sense of when each one is enough:
- A read-only fine-grained token for any agent that only reads. GitHub enforces it whatever else goes wrong.
- Read-only mode or a tool list on the server to keep write tools out of the agent's view, when one configuration serves everyone connected to that server.
- Per-tool grants in a gateway when agents need different small writes on the same server, and you want a record of every call, including the refused ones.
Next tutorials: