Skip to content

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”.

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.

From inside the proxy container, so the master key never has to exist on the host:

Terminal window
docker compose -f deploy/docker-compose.yml run --rm proxy node dist/vault.js set github_service_account < token.txt
docker compose -f deploy/docker-compose.yml run --rm proxy node dist/vault.js list # names only

The 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.

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.

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.

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 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.

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.

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"

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 = 8000

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.

Mention the app in the channel and ask it something the allowlist covers — “what’s open on getlibero/libero”. Then read the log:

Terminal window
docker compose -f deploy/docker-compose.yml run --rm proxy node dist/audit.js list --channel C024BE91L

A 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:

Terminal window
pnpm -r build
LIBERO_GITHUB_PAT= pnpm --filter @getlibero/e2e exec node --test dist/github-live.test.js

From 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.

The messages are deliberately specific about what did and did not happen.

  • “The credential github_service_account is named in this channel’s team sheet but is not in the vault.” — the vault.js set did 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.

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.

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.com
GITHUB_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.

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.

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.