pi can call tools on MCP servers. In pi-dispatch a job connects only the servers you offer in the overlay and
its trigger names. A repo's own .pi/mcp.json is never read for a job, and a server an extension registers is never
connected.
This page covers the overlay file, import-pi, doctor, naming servers in a trigger (run.mcp) and what happens in
the job. The job image must declare the mcp capability (this repository's image does); on one that does not, every
job that names a server is refused before it costs anything.
Servers live in mcp.json in the overlay folder (PI_GLOBAL_PI_DIR), in pi's own mcpServers shape:
{
"mcpServers": {
"docs": {
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${DOCS_TOKEN}" },
"description": "Search the product documentation",
"exposure": "codemode"
},
"files": {
"command": "/opt/pi-global/mcp/files-server",
"args": ["--root", "/workspace"],
"env": { "FILES_TOKEN": "${FILES_TOKEN}" },
"description": "Index of the checkout's files",
"exposure": "deferred"
}
}
}pi-dispatch import-pi writes this file from your own ~/.pi/agent/mcp.json. It keeps only the servers a job can
use, prints each one it staged, and names each one it left behind with the reason. The overlay's file follows your
host's: each import replaces it, and with --no-mcp, with no mcp.json on your host, or with no server a job can
use, the overlay gets none. That includes a file you wrote into the overlay by hand, so either keep your servers in
~/.pi/agent/mcp.json or do not run import-pi over a hand-written one. If your host file cannot be read (not JSON,
a key written twice), the import leaves the overlay's file as it was and says so. pi-dispatch doctor checks the
overlay's file either way.
- A name of lowercase letters and digits, words joined by a single
-, up to 32 characters. A name holdingdispatchis reserved, and so isforge, the worker's own forge server. description, required: one line of at most 300 characters, with no control or invisible formatting character. pi puts it in the job's prompt, so you write the words the agent reads about the server, not the server.- A streamable HTTP server has
urland optionallyheaders. A stdio server hascommand, and optionallyargsandenv.typeis optional (http,streamable-httporstdio). exposureandtoolExposure, as in pi:codemode,deferred,directorhidden.timeoutin whole seconds (1 to 600) andenabled.
The overlay is mounted into every job's container, so a credential is never written in it. You write a reference,
${NAME} with NAME in capitals, digits and _, and the job fills it in from the variables its trigger already
passes, through run.secrets (secrets.md) or PI_FORWARD_ENV. A reference goes wherever the server
wants its key:
- a header:
"Authorization": "Bearer ${DOCS_TOKEN}". A header value is${NAME}, or one of these schemes and a space before it: Bearer, Basic, Token, Bot, ApiKey, Key, Splunk, SSWS, Digest; - an env value: exactly
"${FILES_TOKEN}"; - the url's path or query:
"https://actions.example.com/mcp/${ZAP_KEY}/sse","https://h/mcp?api_key=${KEY}"; - an argument:
["--token=${TOKEN}"],["--header", "Authorization: Bearer ${TOKEN}"].
Header values, env values and query values are always references: none of them is ever written in the overlay,
whatever its name, so a constant there that is not a secret also goes through a trigger variable. A url's host is
written as is (the egress check reads it before the job starts), and a url has no fragment, user name or password.
Write the url plainly, as a browser would show it: lowercase scheme and host, https:// with two slashes, no :443.
doctor and import-pi show the plain form when a url is written another way.
pi itself would run a value starting with ! as a shell command and expand a bare $NAME; pi-dispatch fills in the
references itself, so a secret whose value starts with ! is sent as text.
A literal argument and a literal part of a url's path are written in the overlay as they are, and every job can
read the overlay. Nothing can tell a key from a file name there: two earlier rules that tried (one for header and env
names, one for flags) refused --max-tokens 4096 and still let --api_key=sk-... through. So put a credential in a
reference, and check what you stage: import-pi prints each server it staged.
- A stdio
commandis a bare name found on the job image'sPATH, or an absolute path under/usr,/bin,/sbin,/optor/app: the image's own folders and the overlay at/opt/pi-global. Any other folder is one the job can write (/workspace,/tmp, its home), so install the server in the job image or the overlay. - No
commandor argument starts with~: pi reads it as the job's home folder. - A package runner (
npx,npm,pnpm,pnpx,yarn,bunx,bun,uvx,uv,pipx) is refused unless the entry says"allowPackageRunner": true. Such a server is downloaded at the start of every job that uses it, at whatever version the registry serves, and the job reaches the registry only if you allowlist it. - These rules read the command, not what it runs:
nodeorpython3with a script path, or a launcher such asenvorsh, run whatever their arguments say. That is your choice in your file.
authandoauth: a job cannot finish a sign-in in a browser, and must not hold pi'smcp-auth.json. Give the server a token in a header instead.cwd: a job runs its servers in the checkout.- The legacy SSE transport. Use the server's streamable HTTP URL.
- Any other key. A key a newer pi adds is refused until pi-dispatch knows what it does in a job.
A trigger opts in by name, with run.mcp in triggers.json:
{
"on": { "type": "label", "any": ["docs"] },
"run": { "kind": "github", "flow": "answer", "mcp": ["docs"], "secrets": { "DOCS_TOKEN": "op://ci/docs/token" } }
}A trigger without run.mcp connects no MCP server. Up to 8 names, each from the overlay's file, or forge for the
forge's own server. Before anything is spent, the worker checks each one and refuses the
job when:
| Reason | Why |
|---|---|
mcp-list-invalid |
the list the job carries is not one the loader writes (a service at another version, or a hand-added job) |
mcp-config-invalid |
the overlay's mcp.json is missing or cannot be read whole (missing is fine when the only name is forge) |
mcp-server-unknown |
the file does not offer that name, turns it off, or refuses it (doctor says why); or forge on a job that is not a github or gitlab one |
mcp-secret-reserved |
the server references a credential the job is set up to spend with (the key variables of its own provider and of each provider on its model list, or of every provider when it has no list, and every variable the overlay models.json references), the forge token, or the egress or runner variables. To hand a server a provider's variable on purpose (HF_TOKEN for a Hugging Face server), give the trigger a model list without that provider |
mcp-secret-unmapped |
the server references a variable the job will not have: not in run.secrets, and not a PI_FORWARD_ENV name the worker holds a value for |
mcp-egress-unreachable |
with the egress policy on, the proxy would not let the job reach the server (an http:// url on port 443 included), or egress-allowlist.conf cannot be read; for forge, its API host is not allowlisted |
mcp-package-runner |
with the egress policy on, the registry a package runner fetches from is not allowlisted |
mcp-excluded |
the trigger's run.excludeTools leaves the server no way in (exclude-tools.md) |
job-image-mcp-unsupported |
the job image does not declare the mcp capability |
job-image-forge-mcp-unsupported |
the trigger names forge and the job image does not declare the forgeMcp capability |
trigger-skew |
the trigger names servers but the job arrived without them: the trigger changed after the job was queued, or the receiver is older than the worker |
The comment on the issue names the server and the rule, never a host or a variable; the worker's log has the
detail (refused_mcp). A read of the overlay's file or the allowlist that fails in a way that may pass (EIO, for
one) is retried instead. The job gets its servers in /job/mcp.json, references and all, never a value.
Upgrade the receiver and the worker together. A receiver older than run.mcp drops the field, and the worker then
refuses each such job as trigger-skew rather than running it without its servers.
The registry check reads the runner's name only. A server whose own arguments or environment point the runner at
another registry (--registry, --index-url, npm_config_registry) passes it and fails when it starts, before the
prompt: allowlist the registry it really uses.
The job loads pi's MCP, codemode and tool_search, as the pi CLI does, and connects exactly the servers in
/job/mcp.json, before its prompt:
- Exposure. Each server's
exposureandtoolExposurework as in pi:codemode(the default) reaches the tools from a codemode script,deferredthroughtool_search,directdeclares them to the model,hiddenleaves them out. Resources are read through pi's resource tools (list_mcp_resources,list_mcp_resource_templatesandread_mcp_resource). Each server'sdescriptionbecomes its line in the prompt's list of servers. A codemode script'smodels.classify()andmodels.generateImages()are on, as in the pi CLI: each is metered with the job, held to its model list, and under a cost cap refused when it cannot be bounded (every image call, and a classifier with an output price). A refused call stops the whole job, as any model call the cap or the list refuses does, so a job under a cap cannot try an image model and carry on. An image model pi's catalog prices at zero is counted at zero."codemodeModels": falseat the top of the overlay'smcp.jsonturns them off for every job. - References. The job resolves each
${NAME}once, from its own environment. A value that starts with!is sent as text, never run. - HTTP servers. Each request goes to the server's own origin, through the egress proxy when it is on. A redirect is refused rather than followed, and an HTTP 401 ends the job: a job never signs in.
- stdio servers. The server starts in
/workspacewith its ownenvoverPATH,HOME, the locale, the time zone, the temp folder, the proxy variables and the job's usage meter (NODE_OPTIONS,PI_DISPATCH_CHILD_LEDGERandPI_DISPATCH_RUNNER_PID, so a pi it starts is metered; itsenvmay not replace them). It never sees the provider's key, and never the forge token unless it is the forge's own server: the pickup refuses an overlay entry that names one. It is stopped when the job ends. - Nothing else connects. Not a repository's
.pi/mcp.json, not a server an extension registers, and not a pi the job starts (it gets--no-mcp;pi mcp, the subcommand, is the one residual, open-questions OQ-011). There is no/mcpcommand. An extension that registerscodemode,tool_search, a resource tool, anmcp__tool or/mcpas it loads ends the job before its prompt, and one that registers such a tool later is refused each call to it (a guard against a collision, not against a hostile extension, which can rewrite a call as any in-process extension can).
The job waits for every server to answer: 15 seconds, or a server's own timeout when that is longer, up to 120. A
server counts as answered once pi has listed its tools. It ends before its prompt, on the exit line with:
| Reason | Code | Why |
|---|---|---|
mcp-unavailable |
2 | a server refused: its command did not start, it exited or failed the handshake or the tool list, pi refused an answer, an HTTP error other than 408, 429 or a 5xx other than 501 (401, for example), a redirect, a body over 16 MiB, or its own timeout ran out before it answered |
mcp-unreachable |
1 | a server had not answered when the wait ran out (the network, a 5xx or 429 pi kept retrying, a server still starting); retried like any infrastructure failure |
mcp-shadowed |
2 | another extension registers a name only the job's MCP may |
mcp-command-missing |
2 | a stdio server's command is not in the image |
mcp-secret-missing |
2 | a variable the server references is not set in the job |
mcp-config-mismatch, mcp-config-invalid |
2 | /job/mcp.json is not what the worker writes for this job (a hand-run container, or a worker and image at different versions) |
Each reason with code 2 is the run's reason in its record and in the panel, and pages PI_ON_FAILURE as any paid
stop does. The comment on the issue says which rule stopped the job in a fixed sentence that names no server, host or
variable; the worker's log has the server and the cause, on the job's exit line. mcp-config-invalid is also the
pickup's reason for an overlay file it cannot read; that refusal spends nothing (budgetReserved: false in the
record) and pages nobody.
MCP tool results, server instructions and resource text are content the model reads, like an issue's text: treat a server as you would any input the job did not write. A stdio server runs code inside the job's container with the secrets its entry names.
A github or gitlab trigger can give its job the forge's own MCP server, with the token the job already holds for
gh or glab. Name forge in run.mcp, alone or beside overlay servers:
{
"on": { "type": "label", "any": ["pi"] },
"run": { "kind": "github", "flow": "fix", "mcp": ["forge", "docs"] }
}forge needs no entry in the overlay, and no overlay at all when it is the only name. The worker writes its entry
itself (forgeMcpServer in worker/src/mcp-config.mjs), so neither a trigger nor the overlay can point it anywhere
else, and an overlay server named forge is refused.
| Job | Server | Started as | Token |
|---|---|---|---|
| github | github-mcp-server 2.0.2, GitHub's own | /usr/local/bin/forge-mcp-github, which runs github-mcp-server stdio with GITHUB_TOOLSETS=default and GITHUB_LOCKDOWN_MODE=1 |
GITHUB_PERSONAL_ACCESS_TOKEN from the job's GH_TOKEN |
| gitlab | @zereight/mcp-gitlab 2.1.67 | /usr/local/bin/forge-mcp-gitlab, which runs it with GITLAB_API_URL set to your GITLAB_URL plus /api/v4 and GITLAB_DISABLE_VERSION_CHECK=true |
GITLAB_PERSONAL_ACCESS_TOKEN from the job's GITLAB_TOKEN |
-
Withheld tools. The image's wrapper withholds each server's dedicated merge and approval tools, so the harness does not hand the agent a ready made one (
CONST-MERGE-NEVER-AUTOMATIC):- github:
merge_pull_request, andpull_request_review_write, which can submit an approving review; alsoassign_copilot_to_issueandrequest_copilot_review, which start Copilot's own agent or review and would spend outside the job's meter; - gitlab:
merge_merge_request,approve_merge_request,unapprove_merge_requestandapprove_deployment; alsodiscover_tools, which would turn on toolsets beyond the default ones, andexecute_graphql, which no toolset lists but whose mutations can merge.
They are left out of every tool list, and a call to one, or to any tool the server did not list, is refused before it reaches the server. This is not a boundary. GitLab runs quick actions (
/merge,/approve) written in a note or a description, and the listed tools post notes and descriptions; the job'sghorglab, or the server started from the job's shell, act with the token's full role. What bounds a merge is the token's role and the branch's protection: on GitHub, branch protection (SECURITY.md); on GitLab, a token with the Developer role (not Maintainer) on a protected branch that only Maintainers may merge, and approval rules the bot does not count toward (gitlab.md). - github:
-
No new credential. The server gets the token the worker minted for this job, and nothing else of the job's: not the provider's key, and not the token under its CLI names. It can do no more than the job's
ghorglabcan do with that token. A GitHub installation token lives one hour, and the server stops with the job anyway. -
GitHub's toolsets.
defaultis context, repos, issues, pull requests and users.actions(workflow runs and their triggers) stays off. -
Lockdown (GitHub). On a public repository, the server withholds issue, pull request and comment content written by someone without push access. It is a filter that lowers the chance of a planted instruction reaching the model, not an authorization boundary: the token reads the same things through
gh, and so can the job. -
Pinned in the image. Both servers are baked into the job image at exact versions: github-mcp-server from its release tarball checked against a sha256 per architecture, mcp-gitlab with
npm cifrom a lockfile committed inimage/forge-mcp/gitlab, each behind its wrapper inimage/forge-mcp. The image declares this with theforgeMcpcapability; on an image without it the job is refused before it costs anything (job-image-forge-mcp-unsupported). -
Egress. With the egress policy on, a github job's server needs
api.github.cominegress-allowlist.conf, and a gitlab job's needs the host of yourGITLAB_URL(on port 443, or declared inmodel-endpoints.json). The pickup refuses the job (mcp-egress-unreachable) otherwise.GITLAB_URLmust be a plainhttporhttpsURL with no user name, password, query, fragment or comma (a trailing/api/v4is fine); the pickup refusesforgeon a gitlab job otherwise (mcp-server-unknown, the reason in the worker's log). github-mcp-server reads a file's contents fromraw.githubusercontent.com; allowlist it too if the job reads files through the server rather than from its checkout. -
Not on every kind. A cron trigger is refused at load: a cron job holds no forge token of its own. Forgejo and Azure DevOps triggers are refused too, until a forge MCP server for them has been checked the way these two were.
-
Why not GitLab's own endpoint. GitLab serves MCP itself at
/api/v4/mcp, but it takes an OAuth token with themcpscope, and a project, group or personal access token cannot carry that scope.
What the server's tools return (issue and pull request text, comments, file contents) is content the model reads, like the issue that started the job. Treat it as untrusted input, as you would any text a stranger can write.
- that
mcp.jsonreads, and which servers it offers; - each server it refuses, and why (a job that names it is refused), an overlay server named
forgeamong them; - no
mcp-auth.jsonin the overlay (it offers to delete one); - a server that uses a package runner;
- with the egress policy on, each streamable HTTP server the proxy would not let a job reach. A job reaches a host in
egress-allowlist.confon port 443, or exactly the host and port of a server declared inmodel-endpoints.json(egress.md). Anything else is refused by the proxy, and so is an allowlisted host that is the proxy's own machine (localhost,127.0.0.1). Doctor reads the allowlist as the proxy does, one host per line. It does not look names up, so a name that resolves to the proxy's own machine reads as reachable here and is refused by the proxy. - a server on a host and port declared in
model-endpoints.jsonthat no overlay model'sbaseUrluses: doctor proves the route to it through the proxy, as for any declared endpoint, and passes it once anything answers there (anhttps://server closing the plain request included), without asking it to be a model server (GET /v1/modelsanswering 200).