MCPs: catalog, activation, and who may add one¶
An MCP must be in the host catalog — version-pinned, vendored, checksummed — before any agent can activate it (Architecture § MCP catalog vs drop-to-add). Authoring the catalog is operator-scope; activating a catalogued MCP is self-scope. Nothing in the manifest schema marks an MCP operator-only — what gates one is where its credential has to live in the secret store.
The two verbs and their scopes¶
| Verb | Scope | Enforced at |
|---|---|---|
mcp catalog add\|upgrade\|remove, catalog add |
operator (root or an admin: true agent) |
internal/daemon/authz.go default deny — no case exists |
mcp add\|remove <mcp> <agent>\|self |
self — the target must be the caller | authz.go:216, checkSelfTarget |
mcp catalog list, catalog browse |
read-only, runs locally, ungated | — |
mcp wired <agent> |
own agent locally; a peer needs admin | the PrivilegedRead branch in authz.go |
mcp read <mcp> <tool> [<agent>] |
own agent locally; a peer needs admin | same branch |
mcp call |
own agent locally; cross-agent is root-only | not on the read plane at all; see internal/proto/verbs.go |
mcp read and mcp call are one command under two names: the same parser, the same
flags, and the same admission gate (mcpActionToolAllowed — a tool is reachable unless its
mcp.yaml declares it risk: block, minus a short list of argument keys that reach the local
disk or override a policy). Neither is read-only. The only difference is the row above: mcp
read is on the privileged-read plane, so the admin agent can relay it cross-agent, and it
then runs as the TARGET agent with that agent's credentials.
mcp add and mcp remove are classified Mutating: the invocation is forwarded to agentd,
which checks the caller's uid from SO_PEERCRED and re-execs the verb as root. runMCP then
passes its own requireRoot() (internal/cli/mcp.go:108). See agentd and the unit
model.
What mcp add actually does¶
For a claude agent, in order (mcpAddQ, internal/cli/mcp.go:364):
- Gate on credentials.
renderMCPSecretsruns if the manifest declaresauth: env, or any ofrequired_env/user_env/optional_env. It rebuilds both env files from the secret store, then refuses the wire if anyrequired_envkey does not resolve. The refusal prints the exactagentctl secret set …line per missing key (internal/cli/preflight_secrets.go).--forcedowngrades that to a warning and wires anyway; the unit'sEnvironmentFile=-tolerates the gap, so the MCP starts and fails at the first call. - Render the units. A
.socketand a templated@.serviceunder/etc/systemd/system/, whose content depends onscope:. Staged MCPs (npm,uvx) vendor their code into a deploy dir at this point. - Wire the client. A project-scoped
mcpServersentry in the agent's~/.claude.json, pointing at the socket throughmcp-proxy.sh. Anhttp-transport MCP skips the socket and gets a direct{type: http, url}entry with{KEY}placeholders substituted from the agent's secrets. - Record the ledger delta.
recordMCPwrites an add delta on thetoolscategory ofagents.d/<agent>.yaml— not a resolved list. See Config layering § Membership.
~/.claude.json is read only at process start, so the MCP is not live until the agent
restarts. mcp add says so on the last line.
A pi agent takes the same path, with one substitution at the end. Steps 1–3 are
byte-identical: the same secret render, the same socket and unit pair, the same scope rule.
Only the client config differs — pi reads ~/.pi/agent/mcp.json instead of
~/.claude.json, so mcpAddPi renders that file. Step 4 is identical too: both runtimes
record the MCP under tools:, which is the ONE ledger key an agent declares its MCPs under.
Two consequences worth knowing:
tools:carries exactly one population — MCPs — on both runtimes. There is no split to compute and no name to disambiguate: a name intools:is a catalog MCP, andpiMCPManifestsFromresolves each one straight against the MCP catalog. A host-level binary (zk,anydoc) is not in this key at all — it is installed on every host and reached through plain Bash, with no ledger entry and no per-agent activation.mcp.jsoncarries${KEY}references, never secret values. pi resolves them from the bridge process's environment, which systemd populates from the agent'sEnvironmentFile.
mcp.json is read at process start, so a pi agent also needs a restart before the MCP is
live.
apply keeps it current. mcp_resync.go content-diffs the catalog against the deploy
dir on every apply, re-vendors when the dependency inputs change, and bounces the socket —
without restarting the agent.
Credentialed and uncredentialed MCPs¶
The manifest declares four fields that matter here (internal/cli/manifest.go:29):
| Field | Values | Meaning |
|---|---|---|
auth |
none | env | oauth |
how the server authenticates |
required_env |
key names | activation is refused unless each resolves |
user_env |
key names | per-agent keys, stored under agents.<name> |
optional_env |
key names | rendered if present; never gates |
Of the 22 shipped MCPs, six carry no credential at all (auth: none, empty required_env):
browser, git, agent-message, slack, teams and telegram. Everything else speaks to
somebody's account.
The deb defaults are browser plus the MCP for the agent's own channel, and never a second
channel's:
--channel |
default MCPs |
|---|---|
webview |
browser |
telegram |
browser, telegram |
teams |
browser, teams |
slack |
browser, slack |
TestEveryDefaultMCPCarriesNoCredential fails the build if a credentialed MCP reaches a
default set.
auth: oauth is a two-part credential and the parts have different owners. The client
registration (GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET, or ms365's client and tenant ids)
is one app registration for the host; renderOAuthClient writes it to
/etc/agentctl/env/google-client.json, 0640 root:agents. The user token is per agent:
mcp add creates a 0700 token dir in the agent's home (~/.google-workspace,
~/.ms365-mcp) and the agent fills it with agentctl login <provider> self, a self-scope
verb.
The cockpit's Connections panel is the same verb, reached without a terminal. The
per-agent right panel's "Connections" section (internal/cli/dashboard/js/14-connections.js)
lists one card per wired MCP whose manifest declares auth: oauth — never a hardcoded
list, so it grows the day a fifth provider joins oauthTokenFile in
internal/cli/connections.go. Today that is google (catalog alias google-workspace),
ms365 and atlassian. claude never appears here: that is the agent's own Anthropic
account login, a separate flow covered in First run § Advanced: an agent on a Claude
subscription login.
Pressing Connect calls POST /api/connections/start, which runs agentctl login
<provider> <agent> --json server-side and opens the returned authorize URL in a new tab
(window.open, noopener) — the URL is never rendered into the DOM or written anywhere else.
This is deliberately the replacement for an agent running login <provider> self itself and
having to paste an OAuth URL into a chat message, which it must never do. A Disconnect
button calls POST /api/connections/disconnect, which runs login <provider> <agent> --clear
--json and deletes the provider's local credential-state directory. Both endpoints are
ordinary per-agent cockpit routes gated by allowAgent, the same gate /api/snapshot uses —
not a new authorization case, just this same self-login reached through a safer UI.
Diagnostics. The status pill behind each card is agentctl login <provider> self --status
(or the agent's own name in place of self) — it prints logged-in or needs-login and
exits, the same machine-readable probe the panel polls after a Connect press.
Where the credential lives is the admin gate¶
renderMCPSecrets resolves a required_env key through secretValueFor(agent, key): the
agent's own shard first, the shared shard as fallback. Which place a given key can be
written to is the admin-vs-self boundary, and it is enforced on the secret verb, not the
mcp verb (checkSelfSecret, internal/daemon/authz.go:414):
| Store location | Written by | Rendered into | Readable by |
|---|---|---|---|
agents.<name>.KEY |
secret set KEY --agent self — any agent, for itself |
/etc/agentctl/env/agent-<name>.env, 0640 root:<name> |
that one agent |
top-level KEY (shared) |
secret set KEY with no --agent — operator only |
/etc/agentctl/env/mcp-shared.env, 0640 root:agents |
every agent on the host |
A bare secret set (no --agent) targets the shared store and is refused for a non-operator
in as many words: "pass --agent self; shared/host secrets are operator-only". So:
- An agent can self-serve any MCP whose credential it can mint under its own identity.
githubisscope: per_user: each agent sets its ownGITHUB_PERSONAL_ACCESS_TOKEN --agent <name>and then runsmcp add github self.exadeclares the same shape viauser_env.browser,gitandagent-messageneed no secret. - An agent cannot enable an MCP whose credential is an organisation's, held once for the
host:
perplexity'sPERPLEXITY_API_KEY,notion,leanix, the OAuth client registrations.preflightSecretsrefuses because the key is not in the agent's own shard and it has no way to put one there. Once the operator seeds the shared shard, the MCP self-serves for every agent on the host.
Two consequences:
- Seeding a shared key is a host-wide grant.
mcp-shared.envis0640 root:agents; every agent's process environment carries it and every agent's shell can read the file. A credential two agents must not both hold goes in a per-agent shard. --forceis not a way around this. It skips the refusal, not the credential: the MCP wires with the key absent and its first call fails.
Two shared keys are subtracted from that render regardless of the store's contents —
AGENTCTL_HOST_OP_SECRET and AGENTCTL_HOST_OP_GRANT_SECRET, which sign root executions. See
Security § Secrets.
scope: shared and scope: per_user¶
scope: decides which unit gets rendered (mcpSocketUnit / mcpServiceUnit,
internal/cli/mcp_native.go:299):
per_user |
shared |
|
|---|---|---|
| socket | /run/mcp/<name>-<agent>.sock, SocketMode=0600, SocketUser=<agent> |
/run/mcp/<name>.sock, SocketMode=0660, SocketGroup=agents |
| service | User=<agent>, HOME= the agent's home |
no User= line — it runs as root |
| env | agent-<agent>.env, plus mcp-shared.env when auth: env |
mcp-shared.env when auth: env |
| teardown | on that agent's mcp remove |
only when the last agent drops it |
A shared-scope MCP is a root process behind a socket every member of the agents group can
open. One agent activating it makes the socket reachable by every agent on the host, whatever
their own tools deltas say. Use shared only for a stateless server whose whole input is in
the request (perplexity, image, audio); anything that touches the filesystem or an
identity is per_user.
No verb consults scope: for authorization. Read it before assuming that "agent A has this
MCP wired and agent B does not" is a boundary.
codemode: how a pi agent calls a tool¶
On the pi runtime the model does not call tools one at a time. It writes JavaScript, which runs in a sandbox that can call tools, and only the script's output and return value enter the model's context. Nested tool results do not. Every catalogued MCP on a pi agent arrives this way by default; a manifest has to opt out.
A script reaches, in one run:
| What a script may call | Condition |
|---|---|
every codemode and deferred MCP tool |
whether or not it is active |
every direct tool, built-ins included — bash, read, edit, write |
while it is active |
So a shell command and several MCP calls belong in one script. Chaining bash with MCP
calls inside a script costs one top-level tool call and returns one result, where calling
bash as its own tool and then each MCP tool separately costs a model round trip apiece and
puts every intermediate result in the context.
Three things a script does that separate tool calls cannot:
- Batch independent calls concurrently (
Promise.allSettled) instead of serially. - Chain a call whose arguments come from the previous result, with no round trip between.
- Filter a large result down to the part that matters, so only that part is returned.
Nested calls run through the same tool pipeline as a top-level call, so argument validation
and the risk: gate apply identically — a script is not a way around either. The sandbox
itself has no network, no filesystem and no module loader: it reaches outside only through
the tools above, and bash is reached as one of those tools rather than by the script
shelling out on its own.
exposure: — whether a pi agent's model sees a tool directly¶
An MCP tool is codemode by default: it is not declared to the model, which reaches it
by writing a script, and the top-level tool call is codemode with the MCP tool's own name
only inside that script. A manifest opts out of that default with one of two optional
top-level keys:
| key | shape | values |
|---|---|---|
exposure |
a scalar, for every tool the server offers | codemode | deferred | direct | hidden |
tool_exposure |
a flat <tool>: <exposure> map, overriding per tool |
the same four |
exposure: direct
tool_exposure:
send_message: direct
In the shipped catalog the channel servers (slack, teams, telegram) declare direct,
and agent-message declares it for send_message alone. Everything else is on the default.
Both keys are read only by the pi runtime — the claude runtime declares every MCP tool to the
model unconditionally and ignores them. Neither reaches a built-in tool (bash and the
rest): a built-in is not an MCP tool and stays declared to the model.
direct and codemode are not alternatives. direct controls one thing only: whether the
model is handed the tool as its own declaration. A direct tool stays callable from inside a
codemode script while it is active, and that is what makes bash available to a script
without changing its exposure. hidden is the only value that takes a tool off both paths.
Declare direct for a tool whose output is a message to the owner. The cockpit and the
owner-facing outbound renderer both key on the top-level tool name to paint a message bubble
instead of a tool chip, so an owner-facing tool left on codemode stops being rendered as a
message.
Neither key is a security control. pi runs every MCP call through its normal tool pipeline,
so the risk: gate applies identically to a direct call and to one made inside a codemode
script.
What an agent can self-serve, in full¶
Self-scope, no operator needed:
agentctl secret set <KEY> --value <v> --agent self # its own shard
agentctl secret get <KEY> # rewritten to --agent self
agentctl mcp add <mcp> self # activate a CATALOGUED MCP (same verb on both runtimes)
agentctl mcp remove <mcp> self
agentctl login <provider> self # its own oauth token (cockpit: the Connections panel does this, see above)
agentctl apply self
agentctl agents restart self
Operator or admin agent only:
agentctl mcp catalog add <name> --npm <pkg@ver> # pin a NEW MCP into the catalog
agentctl mcp catalog upgrade <name> --to <ver> # move a pin
agentctl mcp catalog remove <name>
agentctl secret set <KEY> --value <v> # the SHARED shard — no --agent
agentctl mcp add <mcp> <some-other-agent>
agentctl mcp read|wired <mcp> … <some-other-agent>
The operator curates what exists and holds what is shared; the agent chooses from it for itself. An agent cannot introduce a new server, move a version pin, grant itself a credential it does not already own, or touch a peer.
The gate model is not this boundary¶
Everything above is authorization — who may wire what. The runtime permission gate is
separate: for an irreversible tool call on an MCP that is already wired it either asks a
human (risk: ask) or refuses outright with no approval path (risk: block — 22 destructive
google-workspace and ms365 tools today). That gate is generated from the manifest's risk:
block and matches tool names and command strings. An agent that reads its own token out of
its environment and calls the vendor's REST API with curl matches nothing. The full treatment, including the
raw-api-access mitigation and its limits, is in Security § The human floor on sends and
deletes.
Traps¶
- The first apply after a change to the default MCP set restarts every claude agent on the
host, because
~/.claude.jsonis read only at start. mcp catalog removerefuses while any agent still has the MCP wired, and names them.catalog removeon a shipped (deb) item is refused outright — it would return on the nextapt upgrade.agentctl defaults add tools <mcp>validates against the whole MCP catalog, not against the channel-conditional deb default, so a host overlay can bless any catalogued MCP for every agent on the host. See Config layering.mcp removeon a shared MCP leaves the socket running if any other agent still has it, and says which. The agent's own access ends (itsmcpServersentry is gone); the socket's0660 root:agentsmode does not change.mcp catalog adddefaults to the repo overlay, not to/var/lib/agentctl/catalog. The runtime catalog is used only on a host with no repo, or with an explicit--into. Both lose to the overlay on resolution.