Skip to content

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

  1. Gate on credentials. If the manifest declares auth: env, or any of required_env / user_env / optional_env, renderMCPSecrets runs. It rebuilds both env files from the secret store, then refuses the wire if any required_env key does not resolve — printing 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 instead of at wire time.
  2. Render the units. A .socket and a templated @.service under /etc/systemd/system/, whose exact content depends on scope: — see the next section. 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 steps 2 and 3's socket entirely 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 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 selfany agent, for itself /etc/agentctl/env/agent-<name>.env, 0640 root:<name> that one agent
top-level KEY (shared) secret set KEY with no --agentoperator 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 the worked case — the manifest is scope: 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 own GITHUB_PERSONAL_ACCESS_TOKEN --agent <name> and then runs mcp add github self. exa is the same shape declared explicitly via user_env. browser, git and agent-message need no secret at all.
  • 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. The agent may run mcp add perplexity self all it likes; preflightSecrets refuses 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.env is 0640 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.
  • --force is 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.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> does not work. tools is a delta category and the reader accepts a tools: block, but defaultsCatalogEntries() has no tools case, so validation always rejects it. Blessing a default MCP for a host means hand-editing <repo>/defaults.yaml. 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.