Connecting GitHub
GitHub publishes a hosted MCP server at https://api.githubcopilot.com/mcp/. It is the first real
upstream Libero was built against, and connecting it is three things: a token in the proxy’s vault,
one [[mcp_server]] block per toolset in the channel’s team sheet, and a mention.
The token goes into the proxy and stays there. The agent process — the one running the model, the one a prompt injection reaches — never receives it, never learns its value, and knows it only by the name written in the sheet. That is the property the whole two-service split exists for, and it is the reason this page is longer than “paste a URL”.
1. Mint a token
Section titled “1. Mint a token”Use a fine-grained personal access token, on an account that exists for this and nothing else. The agent will act as whoever this token is.
Grant the least the toolsets you are about to allowlist actually need. For the pull-request reads
below that is Metadata: read-only, Contents: read-only, and Pull requests: read-only,
scoped to the repositories the channel should see. A classic PAT with the repo scope also works
and is worse: it is every repository the account can reach, with write.
Two separate boundaries are in play and you want both:
- GitHub’s, the token’s permissions. This is what stops a mistake in a team sheet from becoming a write.
- Libero’s, the team sheet’s tool allowlist and approval marks. This is what stops the model calling a tool nobody meant it to have, and what puts a human in front of the ones that matter.
Neither substitutes for the other. A read-only token with a careless sheet is noisy; a careful sheet with an owner-scoped token is one bug away from a force-push.
2. Load it into the vault
Section titled “2. Load it into the vault”From inside the proxy container, so the master key never has to exist on the host:
docker compose -f deploy/docker-compose.yml run --rm proxy node dist/vault.js set github_service_account < token.txtdocker compose -f deploy/docker-compose.yml run --rm proxy node dist/vault.js list # names onlyThe value is read from stdin rather than an argument, because ps shows arguments to every user on
the box and a shell writes them to history. There is no command that prints a credential back. The
proxy reads the vault at startup, so a new entry takes effect on restart.
github_service_account is the name the team sheet will refer to. Names travel; values do not.
3. Point a channel at it
Section titled “3. Point a channel at it”In channels/<channel id>/channel.toml:
[[mcp_server]]name = "github"transport = "http"url = "https://api.githubcopilot.com/mcp/x/pull_requests/readonly"credential = "github_service_account"
[[mcp_server.tool]] name = "list_pull_requests" approval = "none"
[[mcp_server.tool]] name = "pull_request_read" approval = "none"That is a complete, safe first connection: a server-side read-only endpoint, and a two-tool allowlist inside it. Sheets are picked up on file change, so no restart is needed for this part.
The url is the configuration
Section titled “The url is the configuration”GitHub’s hosted server takes its configuration two ways, and only one of them is reachable from a team sheet.
| URL | What it publishes |
|---|---|
https://api.githubcopilot.com/mcp/ |
the default toolsets |
https://api.githubcopilot.com/mcp/readonly |
the same, minus every write tool |
https://api.githubcopilot.com/mcp/x/pull_requests |
one toolset |
https://api.githubcopilot.com/mcp/x/pull_requests/readonly |
one toolset, reads only |
The toolset names are GitHub’s: pull_requests, issues, repos, actions, code_security,
dependabot, discussions, gists, notifications, orgs, projects, secret_protection,
security_advisories, users, and about ten more. /x/all exists. Do not use it: the proxy
describes at most 100 tools per upstream to the model, and a catalog that large is a large per-turn
context cost for tools the channel is not allowed to call anyway.
The other way is a set of X-MCP-Toolsets, X-MCP-Readonly and X-MCP-Tools request headers.
A team sheet has no field for request headers and should not grow one. A headers field is a
place to write a token into the one file whose defining property is that it holds none, and the URL
path says the same thing in a field that is already reviewed.
Get the url exactly right
Section titled “Get the url exactly right”Redirects are not followed. A redirect target is the one destination in the system that nothing
declared — the sheet named a url, and a 302 is chosen by the upstream at call time — so the proxy
refuses one rather than following it. In practice this means https://api.githubcopilot.com/mcp and
https://api.githubcopilot.com/mcp/ are not interchangeable, and a near-miss surfaces as a failed
call rather than as a silently corrected one.
The /x/<toolset> forms carry no trailing slash. Copy them.
One block per toolset
Section titled “One block per toolset”One block holds one url, and for this server one url is one toolset. A channel that needs pull requests and repository contents gets two blocks:
[[mcp_server]]name = "github"transport = "http"url = "https://api.githubcopilot.com/mcp/x/pull_requests"credential = "github_service_account"# …tools…
[[mcp_server]]name = "github_repos"transport = "http"url = "https://api.githubcopilot.com/mcp/x/repos"credential = "github_service_account"# …tools…One credential, however many blocks. Give each block a different name. Two blocks may share a
name — that is how a long tool list gets split — but only if every block carrying a given tool
agrees on the upstream, and these two do not point at the same url. Sharing a name here would
refuse the call as server_ambiguous.
The model sees the bare tool name, list_pull_requests, not github__list_pull_requests. The
server name only enters the model-facing name when two servers publish the same tool.
The allowlist is still the boundary
Section titled “The allowlist is still the boundary”A toolset path narrows what GitHub publishes. [[mcp_server.tool]] decides what exists for the
channel: a tool not listed is not in the definitions the agent fetches, and a call to it is refused
in the proxy regardless.
So /readonly is defence in depth rather than a replacement for reading the tool names. Both, or
neither will save you.
An entry naming a tool the toolset does not publish is not an error — it stays in the allowlist with no description and no argument schema, and fails at GitHub when called. If a tool you allowlisted answers “unknown tool”, check which toolset it lives in before you check the spelling.
4. Approval, and GitHub’s tool names
Section titled “4. Approval, and GitHub’s tool names”A tool marked approval = "required" is held in the proxy and raises an Approve once / Deny card in
the thread. A tool that says nothing falls to a heuristic: a name containing delete, drop,
transfer, or deploy is held.
Read GitHub’s tool names against that list before you rely on it. Almost none of GitHub’s destructive tools are named in a way the heuristic catches:
| Held by the heuristic | Not held — mark these yourself |
|---|---|
delete_file |
merge_pull_request |
delete_workflow_run_logs |
push_files |
create_or_update_file |
|
issue_write |
|
pull_request_review_write |
|
create_pull_request, update_pull_request |
|
fork_repository, create_repository |
|
run_workflow, cancel_workflow_run |
This is the heuristic working as designed rather than failing. It is a default for the entry nobody
thought about, and an explicit approval in the sheet always wins. It is not a substitute for
having thought about them:
[[mcp_server.tool]] name = "merge_pull_request" approval = "required"5. What the model is handed
Section titled “5. What the model is handed”The proxy asks GitHub for its tools/list and publishes each allowlisted tool’s description and
argument schema to the model, so the model calls tools accurately rather than guessing.
Those descriptions are upstream-authored text that enters the model’s context on every turn. Nothing in Libero reads them or makes decisions from them — a rule that read a description is a rule an upstream phrases its way around — so what bounds them is size, not content: a description is truncated at 1,024 characters, a schema is dropped past 8 KB, at most 100 tools per upstream are described, at most five catalog pages are walked, and the whole walk gets five seconds. Naming the server in the sheet is what accepts that text. Name servers you would accept text from.
Tool results are bounded separately, by the channel: [llm] max_result_chars (32,768 by default),
overridable per tool. A GitHub PR list is worth less context than a diff, so:
[[mcp_server.tool]] name = "list_pull_requests" approval = "none" max_result_chars = 8000A wrinkle GitHub is entitled to
Section titled “A wrinkle GitHub is entitled to”GitHub’s tool schemas annotate owner and repo with x-mcp-header, which asks a client to mirror
those argument values into Mcp-Param-{name} request headers. That is a 2026-07-28 feature, and
GitHub negotiates the older 2025-11-25 revision — but it requires the headers anyway, declining the
specification’s optional allowance for older clients. Both ends are within spec, and between them
sits a hole: no released MCP client library sends those headers on that connection, so every call to
an annotated tool — which is nearly all of them — used to come back -32020, “missing
Mcp-Param-owner header”.
Libero sends them. Nothing to configure, and it is mentioned here only because a -32020 in your
logs is otherwise a mystery with no obvious owner, and because it is the reason a small piece of the
MCP SDK is vendored into packages/proxy/src/vendor/. Filed upstream as
typescript-sdk#2639.
6. Verify it
Section titled “6. Verify it”Mention the app in the channel and ask it something the allowlist covers — “what’s open on getlibero/libero”. Then read the log:
docker compose -f deploy/docker-compose.yml run --rm proxy node dist/audit.js list --channel C024BE91LA served call is one row, outcome = ran, naming the server, the tool, the requester, a hash of the
arguments, and the size of the result. That row is the demonstration; the reply in the thread is
only the visible part of it.
What the row attests, step by step: the channel was resolved from the client certificate, the sheet permitted the tool, the credential was found in the vault and accepted by GitHub, and the call was metered and recorded.
The verification is also automated. e2e/src/github-live.test.ts in the repository runs exactly
this path against real GitHub and is skipped unless LIBERO_GITHUB_PAT is set:
pnpm -r buildLIBERO_GITHUB_PAT=… pnpm --filter @getlibero/e2e exec node --test dist/github-live.test.jsFrom a channel whose sheet has no GitHub block, the same question gets “This channel’s team sheet
does not list the server github. The call was not made.” and an outcome = refused row. A
channel with no sheet at all gets “This channel has no team sheet, so no tool call is permitted.”
and nothing leaves the proxy.
When it does not work
Section titled “When it does not work”The messages are deliberately specific about what did and did not happen.
- “The credential
github_service_accountis named in this channel’s team sheet but is not in the vault.” — thevault.js setdid not land, or the proxy has not restarted since it did. - “The tool endpoint answered HTTP 401.” — GitHub rejected the token. Check it has not expired and that its permissions cover the toolset.
- “The tool server could not be reached:
redirected.” — the url. See above. - “The tool server does not speak a version of MCP this proxy supports.” — not something GitHub’s hosted server should produce; if you see it, the url is reaching something else.
- “The tool server’s answer was larger than this proxy will accept.” —
PROXY_MAX_RESPONSE_BYTES, a deployment setting, 4 MiB by default. The call was made; the answer was discarded. - “This proxy is already running as many calls to that tool server as it allows.” —
PROXY_MAX_UPSTREAM_CONCURRENCY, 8 by default. Nothing is wrong with GitHub: the call queued behind others to the same server and none finished in time. Raise it if your token’s rate limit has room, or leave it — the model is told plainly and can try again.
The token appears in none of these, in no log line, and in no result relayed to the model. If you ever find it in one, that is a security bug and SECURITY.md is the way to report it.
GitHub Enterprise
Section titled “GitHub Enterprise”Enterprise Cloud with a ghe.com subdomain has its own host, and the rest of this page is
unchanged:
url = "https://copilot-api.<subdomain>.ghe.com/mcp"Enterprise Server has no hosted MCP endpoint; it needs GitHub’s local server, which speaks stdio
rather than HTTP. transport = "stdio" is in the schema and is not implemented — a stdio upstream
is a process the proxy would have to spawn and sandbox, which is
#154, decided post-1.0.
A bridge stands in for it. Nothing in the rest of this section has been run. No deployment here has an Enterprise Server to run it against, so what follows is how the pieces fit rather than a report of them fitting, and every claim in it is yours to verify before you rely on it.
The shape
Section titled “The shape”A bridge container beside the proxy, on the proxy’s network, running GitHub’s local server as its child over stdio and presenting HTTP on the other side. The bridge is the operator’s to choose. What it has to do is narrow, and one requirement is not negotiable: the proxy’s client is streamable HTTP, with no SSE fallback, so a bridge that publishes only an SSE endpoint will not serve a call.
The child is ghcr.io/github/github-mcp-server, run as github-mcp-server stdio, and it takes the
host and the token from its environment:
GITHUB_HOST=https://github.example.comGITHUB_PERSONAL_ACCESS_TOKEN=<the token>GITHUB_HOST must carry the https:// scheme — the local server refuses a cleartext host, so the
token is never sent in the clear — and a PAT takes precedence over the server’s OAuth flow, which
is what you want for a container nobody is sitting in front of.
The sheet then names the bridge as an ordinary HTTP upstream at its address on the proxy’s network:
[[mcp_server]]name = "github"transport = "http"url = "http://github-bridge:8080/mcp"
[[mcp_server.tool]] name = "list_pull_requests" approval = "none"There is no credential: the token is in the bridge, and the proxy has nothing to attach. The
rest of this page is unchanged, because the proxy cannot tell a bridge from a hosted server. The
allowlist still decides which tools exist, approval still puts a human in front of the ones that
matter, the budget meter still counts the calls, and every decided call still leaves an audit row.
What it costs, in plain terms
Section titled “What it costs, in plain terms”The bridge container holds that token outside the vault. vault set does not manage it,
rotation is yours, and nothing in Libero redacts it.
The property that survives is the narrower one this page opened with: tool credentials do not reach the agent process. The agent reaches the bridge only through the proxy, so a prompt-injected or compromised agent gets served calls and refusals, never the token — exactly as with a hosted upstream. What that claim does not cover is the bridge itself, a process holding a credential the vault never sees. A compromised bridge is that token.
Two consequences follow, and both are yours to enforce:
- Put the bridge on the proxy’s network and nowhere else. It answers without authentication, so anything that can reach it can spend the token.
- Scope the token as narrowly as step 1 argues. On this path it is the only boundary left on the GitHub side.
Not a supported shape
Section titled “Not a supported shape”No compose profile starts a bridge, doctor does not check for one, and none of it is tested here.
It is a way through for a team that would otherwise have none, written down with its cost stated.
The supported answer is #154.