Skip to content

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

  1. Gate on credentials. renderMCPSecrets runs if the manifest declares auth: env, or any of required_env / user_env / optional_env. It rebuilds both env files from the secret store, then refuses the wire if any required_env key does not resolve. The refusal prints the exact agentctl secret set … line per missing key (internal/cli/preflight_secrets.go). --force downgrades that to a warning and wires anyway; the unit's EnvironmentFile=- tolerates the gap, so the MCP starts and fails at the first call.
  2. Render the units. A .socket and a templated @.service under /etc/systemd/system/, whose content depends on scope:. Staged MCPs (npm, uvx) vendor their code into a deploy dir at this point.
  3. Wire the client. A project-scoped mcpServers entry in the agent's ~/.claude.json, pointing at the socket through mcp-proxy.sh. An http-transport MCP skips the socket and gets a direct {type: http, url} entry with {KEY} placeholders substituted from the agent's secrets.
  4. Record the ledger delta. recordMCP writes an add delta on the tools category of agents.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 in tools: is a catalog MCP, and piMCPManifestsFrom resolves 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.json carries ${KEY} references, never secret values. pi resolves them from the bridge process's environment, which systemd populates from the agent's EnvironmentFile.

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. github is scope: per_user: each agent sets its own GITHUB_PERSONAL_ACCESS_TOKEN --agent <name> and then runs mcp add github self. exa declares the same shape via user_env. browser, git and agent-message need no secret.
  • An agent cannot enable an MCP whose credential is an organisation's, held once for the host: perplexity's PERPLEXITY_API_KEY, notion, leanix, the OAuth client registrations. preflightSecrets refuses 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.env is 0640 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.
  • --force is 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.json is read only at start.
  • mcp catalog remove refuses while any agent still has the MCP wired, and names them. catalog remove on a shipped (deb) item is refused outright — it would return on the next apt 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 remove on a shared MCP leaves the socket running if any other agent still has it, and says which. The agent's own access ends (its mcpServers entry is gone); the socket's 0660 root:agents mode does not change.
  • mcp catalog add defaults 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.