MCPs: catalog, activation, and who may add one¶
MCPs are the one thing that uses an operational catalog, because they are external packages that must be version-pinned, vendored and checksummed. Catalog membership (the pinned set on a host) is separate from activation (turning one on for one agent); the unix socket is created on activation and torn down on the last deactivation. The catalog model and the credential rule that keeps almost every MCP out of the deb defaults are in Architecture § MCP catalog vs drop-to-add.
This page is the authorization half: which of those two halves an agent may drive for itself, and why "the operator has to enable that one" is true for some MCPs and not others.
The short answer, up front, because it is not what the shape of the CLI suggests:
- Authoring the catalog is operator-scope.
mcp catalog add|upgrade|removeare absent from the daemon's self-scope switch and fall to its default deny. - Activating a catalogued MCP is self-scope. Any agent may run
mcp add <mcp> self. - There is no admin flag on an MCP. Nothing in the manifest schema marks one operator-only. What makes an MCP effectively admin-gated is where its credential has to live in the secret store — and writing the shared half of that store is operator-scope.
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:309 |
mcp add and mcp remove are classified Mutating, so an agent's invocation is forwarded
to agentd, which checks the caller's uid from SO_PEERCRED and then re-execs the verb as
root. runMCP starts with requireRoot() (internal/cli/mcp.go:108) — that passes because
the daemon already re-execed, not because the agent is privileged. The gate that matters ran
before the process existed. The general model is in
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. If the manifest declares
auth: env, or any ofrequired_env/user_env/optional_env,renderMCPSecretsruns. It rebuilds both env files from the secret store, then refuses the wire if anyrequired_envkey does not resolve — printing 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 instead of at wire time. - Render the units. A
.socketand a templated@.serviceunder/etc/systemd/system/, whose exact content depends onscope:— see the next section. 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 steps 2 and 3's socket entirely 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 a different path. pi has no native MCP client, so mcpAddPi wraps the
manifest as an mcptools-fronted CLI tool and activates that
(internal/cli/tool_wrapmcp.go). No socket, no ~/.claude.json. Note the consequence for
this page's subject: ensureWrappedTool writes the generated tool.yaml / cli.sh /
SKILL.md into the repo overlay (<repo>/tools/<name>) and commits it, so a self-scope
mcp add on pi does write shared catalog state. It is derived state — generated from a
manifest the operator already curated, idempotent, and it never overwrites an existing tool —
but it is not confined to the agent's home the way the claude path is.
apply keeps it current. mcp add is the only writer of the units and the staged copy,
and it keys on socket-unit absence, so nothing is rewritten once wired. mcp_resync.go on
every apply content-diffs the catalog against the deploy dir, re-vendors when the
dependency inputs change, and bounces the socket — without restarting the agent.
Credentialed and uncredentialed MCPs¶
The manifest declares three things 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 17 shipped MCPs, three carry no credential at all: browser, git and
agent-message (auth: none, empty required_env). Everything else speaks to somebody's
account. That is why a catalogued MCP does not become a deb default — blessing one
fleet-wide would hand every agent on every host access it was never granted — and why
browser is the sole exception, pinned by TestEveryDefaultMCPCarriesNoCredential.
auth: oauth is a two-part credential and the two 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 by running
agentctl login <provider> self, which is a self-scope verb.
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. So an MCP activates as soon as its
keys resolve from either place. Which place a given key can be written to is the entire
admin-vs-self boundary, and it is enforced on the secret verb, not on 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.
githubis the worked case — the manifest isscope: per_user, and its own comment says a PAT is minted against one account and must not leak into other agents' environments, so each agent that wants GitHub sets its ownGITHUB_PERSONAL_ACCESS_TOKEN --agent <name>and then runsmcp add github self.exais the same shape declared explicitly viauser_env.browser,gitandagent-messageneed no secret at all. - 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. The agent may runmcp add perplexity selfall it likes;preflightSecretsrefuses because the key is not in its own shard and it has no way to put one there. The operator seeds the shared shard, and from that moment the MCP self-serves for every agent on the host — because the shared shard is the fallback for all of them.
Two consequences worth stating plainly:
- Seeding a shared key is a host-wide grant, not an agent-wide one.
mcp-shared.envis0640 root:agents; every agent's process environment carries it and every agent's own shell can read the file. If two agents must not hold the same credential, it goes in a per-agent shard, not the shared one. --forceis not a way around this. It skips the refusal, not the credential: the MCP is wired 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¶
The manifest's scope: decides what unit gets rendered, and the difference is a privilege
difference (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 therefore 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. That is acceptable for a stateless server whose whole
input is in the request (perplexity, image, audio) and wrong for anything that touches
the filesystem or an identity — which is what the git manifest's header records at length:
it shipped shared in an overlay on the reasoning that the server holds no credential, and
that made every write tool a root-level git write on any path on the host, reachable from any
agent. It is per_user now, and the manifest says why.
So scope: is not an admin gate — no verb consults it for authorization — but it is the field
to read before assuming that "agent A has this MCP wired and agent B does not" is a boundary.
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
agentctl mcp remove <mcp> self
agentctl login <provider> self # its own oauth token
agentctl tool add|remove <tool> self # the pi equivalent of mcp add
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 tool wrap-mcp <name> # author the pi wrapper deliberately
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 line between the two lists is consistent: the operator curates what exists and holds what is shared; the agent chooses from it for itself. An agent cannot introduce a new server, cannot move a version pin, cannot grant itself a credential it does not already own, and cannot touch a peer.
The gate model is not this boundary¶
Everything above is authorization — who may wire what. It is orthogonal to the runtime
permission gate, which asks a human before an irreversible tool call on an MCP that is
already wired. That gate is generated from the manifest's risk: block and matches tool
names and command strings, so an agent that reads its own token out of its environment and
calls the vendor's REST API with curl matches nothing. The credential is in the agent's
environment by construction; the gate is a floor against carelessness, not against intent.
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>does not work.toolsis a delta category and the reader accepts atools:block, butdefaultsCatalogEntries()has notoolscase, so validation always rejects it. Blessing a default MCP for a host means hand-editing<repo>/defaults.yaml. 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.