Skip to content

External MCP servers (kapelle.controller.command, command: "mcp")

Backlog task d6b93b0c (design doc §15 "Skills and MCP servers" -> "MCP servers"). Reuses the same channel docs/memory-commands.md and docs/skills.md document for /memory//team//skills -- plain core NATS request/reply, kapelle.controller.command, one queue-group subscriber on the controller side (kapelle_controller.commands.ControllerCommandServer) -- so both the worker's mcp_propose MCP tool and the gateway's /mcp surface commands share one implementation (kapelle_controller.mcp_servers.store.McpServerStore).

What an MCP server declaration is

Skills give an agent procedures; MCP servers give it tools it doesn't have: a ticket tracker, a documentation index, a database console, a browser. A declaration is the SDK's MCPServer shape plus a destination field:

name: linear
transport: http                      # http (streamable HTTP) or stdio
url: https://mcp.linear.app/mcp
destination: linear-mcp              # the credential gateway destination that carries the secret
roles: [planner, reviewer]           # optional; default every role
auth: bearer                         # none | bearer | api_key:<header> | basic:<user>
tools: {allow: ["*"], deny: ["delete_*"]}
default: false                       # platform declarations only; see "Platform is a catalog" below

services/worker/src/kapelle_worker/agent/mcp_servers.py's McpServerDeclaration is the schema every source is parsed into (name, transport, url/command+args for stdio, destination, auth, roles, tools, default, env for stdio -- every env value must be the literal placeholder, never a real secret).

Where MCP servers come from

Source Location Who maintains it Scope
Platform roles/mcp/<name>.yaml in this repo, shipped in the agent image Kapelle maintainers A CATALOG, not attached by default -- see below; role-filtered by the declaration's own roles field
Template mcp_servers: list in teams/templates/<template>.yaml, names resolved against the platform catalog Whoever owns the template Every team created from the template
Repository .kapelle/mcp/<name>.yaml in the team's repository, read from the default branch (never the agent's own working branch) The team, through ordinary PRs That repository
Skills-bundled The SDK's own Skill.mcp_tools on every skill a conversation resolved Whoever owns the skill Wherever that skill applies
Team Stored by the controller (this document), through /mcp Team members One team, across its repositories

Declarations merge by name in that order (kapelle_worker.agent. mcp_servers.merge_mcp_servers), later sources overriding earlier ones -- skills-bundled servers slot in between repository and team: more specific than a bare platform/template declaration, less specific than a person explicitly declaring or overriding one through the team store. After the merge, a declaration whose roles names other roles is dropped for this one, a declaration whose destination doesn't exist in the role's own rendered credgw policy is dropped with a WARN -- "a declaration cannot widen a role's credential policy" (design doc §15) -- and a declaration naming one of the four GitHub-token-gated destinations (github-api, github-git, github-repo-metadata, github-mcp) is dropped with a WARN for a team with no repo_url configured (nothing to mint github_installation_token against -- credgw/manager.go's own scopeGitHubDestination drops those destinations server-side for the identical reason).

Platform is a catalog, not a default-attach list

Backlog task c81da673, a real regression: roles/mcp/*.yaml used to be folded into every conversation for a role that could see it, no opt-in required -- so a platform declaration with a credential that wasn't actually available for a given team or environment (the very first live proof's own GitHub MCP declaration, in a test harness with no real credgw) got attached anyway and crashed the whole conversation on connect (docs/upstream/openhands-mcp-init-failure.md). A platform entry is now attached only when something explicitly references it by name -- a template's mcp_servers: list, a repository declaration, a team-stored declaration, or a skill's own mcp_tools -- UNLESS the platform file itself sets default: true, meaning "safe and intended for every role that can see it, no explicit opt-in needed" (nothing in this repo sets it yet; every current platform entry is catalog-only).

Team MCP servers: two kinds of caller

  • Worker-originated (an agent's mcp_propose MCP tool call): sets source explicitly (e.g. "agent:payments/coder") and omits user. The controller skips its team-membership check when source is already set -- the MCP server's own per-agent bearer-token auth already established which team this call is scoped to.
  • Person-originated (a /mcp ... surface command, kapelle_gateway.core.GatewayCore._handle_controller_command): omits source, sets user the same way /team//memory//skills do. The controller checks team membership (_MEMBER_OR_ABOVE, same bar as /memory//skills) and derives source = "person:<person_id>" itself.

Request

{
  "command": "mcp",
  "team": "payments",
  "args": ["propose", "linear", "name: linear\ntransport: http\n..."],
  "source": "agent:payments/coder"
}

args[0] is the operation; the rest are its positional arguments.

args Operation
["show", name] (alias: ["get", name]) McpServerStore.get
["list"] McpServerStore.list_all -- every team server, curated first then proposed, name ascending within each group. entries are McpServerEntry.render()'d display blocks.
["list_curated"] Curated servers only, entries are JSON-encoded {"name", "declaration"} objects (the real raw declaration text, not a rendered block) -- how kapelle_worker.agent.mcp_servers.load_team_mcp_servers resolves the team source at run start, cached briefly per team.
["propose", name, declaration] McpServerStore.propose -- upserts the team's one row for name (a re-proposal of an existing name updates that row rather than creating a duplicate), always leaves it proposed. declaration is the full raw declaration text (YAML or JSON), never parsed or validated by the controller -- see kapelle_controller.mcp_servers.db's own module docstring.
["accept", name] McpServerStore.accept -- idempotent.
["forget", name] McpServerStore.forget -- hard delete.

Only list/show/accept/forget are exposed as /mcp gateway commands (design doc §15's own scope for the person-facing surface); propose is reached only through the worker's mcp_propose MCP tool in normal operation, though the controller command itself doesn't distinguish the caller beyond the authorization check above.

Response

Same shape as /skills's:

{"ok": true, "text": "1 mcp server(s)", "entries": ["[curated] linear (from person:1)\n..."]}
  • text: always present on success, a one-line human-readable summary.
  • entries: present only for list -- each element is one McpServerEntry.render()'d block ("[status] name (from source)\ndeclaration"), ready to show a model or a person as-is.
  • error: chat-ready, same convention as /team//memory//skills's errors.

How credentials flow

The agent's MCP client is configured exactly as the LLM client is: the literal placeholder as the bearer token, API key or basic password (design doc §9, placeholder-header contract). Every declared server is a named destination in the role's credential-gateway policy, and the real secret lives in OpenBao under the team. iron-proxy swaps the placeholder for the real value on the way out, so a server declared without a matching destination is unreachable rather than insecure. Remote (streamable HTTP) servers reach their upstream through the proxy from the guest; stdio servers run inside the sandbox with placeholder in their environment, so any HTTP call they make to an allowed destination gets the real credential the same way.

auth_headers(decl) (kapelle_worker.agent.mcp_servers) is the framework-agnostic translation from a declaration's auth field to the literal header(s) sent -- used directly by the Pydantic AI and Deep Agents runtimes' own MCP client attachment; OpenHands uses the equivalent SDK-native MCPAuthCredential conversion (build_mcp_server).

Stdio transport

OpenHands runs its agent-server inside the guest, so it can spawn a stdio MCP server there. The Pydantic AI and Deep Agents runtimes run their whole agent loop client-side in the worker process, with no guest process of their own to spawn one in -- a stdio declaration is dropped for those two runtimes (with a WARN log) rather than attempted; only http (remote, streamable-HTTP-through-credgw) servers attach for them. See services/worker/src/kapelle_worker/agent/lean_runtime_executors.py's own module docstring for the full reasoning.

Safety

Every propose declaration is scanned (kapelle_controller.memory.scan.scan_entry_body, reused as-is) for credential-looking patterns and links to hosts outside a small allowlist before it's stored -- a rejection comes back as a normal ok: false reply, error naming what tripped it. Repository declarations are loaded only from the default branch by construction (kapelle_worker.agent.mcp_servers.load_repository_mcp_servers), and a declaration cannot widen a role's credential policy: the destination has to exist in the role's policy already. The worker reads the role's destination names from the controller's GetEgressPolicy (the policy credgw itself enforces: the builtins plus the egress destinations of the agent version or the team's override, in both modes) and falls back to credgw/policy/roles/<role>.yaml when the controller has no policy for the role or cannot be reached; one INFO line names the source.

The Console manages a team's own servers (POST/PUT/DELETE /api/v1/mcp-servers, POST /api/v1/mcp-servers/{team}/{name}/accept): the declaration is validated with the worker's own model, stored as canonical JSON and curated at once, and reaches every role of the team (roles narrows it). Its credential is the inject_secret of the destination it names (secret:<ref>, entered with PUT /secrets/{ref}); a team that needs its own credential gets its own destination. Attaching a platform server to an agent is a new agent version whose mcp_servers GetTeamRole unions with the names of the team's template.

A proposed team MCP server that nobody accepts expires after 30 days, same convention as team memory and team skills (kapelle_controller.mcp_servers.expiry_loop).

Attribution

Every external MCP server actually attached to a run is recorded on the activity feed and the outcome's mcp_servers artifact as name (destination) (mcp_servers_attribution), so a run can be traced back to exactly which servers it could call. Per-tool-call attribution (which server a given call went to) needs no separate mechanism: iron-proxy's own audit log already records the destination for every request it handles, keyed by the same agent identity already tied to the conversation elsewhere (design doc §16 "Credential audit").

Tool filtering (tools.allow/deny)

Enforced per server, differently per runtime family (backlog task fbfd3360; kapelle_worker.agent.mcp_servers' own module docstring has the full mechanism):

  • Pydantic AI / Deep Agents: each declared server gets its own toolset/tool list, tool names never prefixed -- tool_allowed(name, tools) is applied directly.
  • OpenHands: Agent.filter_tools_regex, one combined regex built by openhands_tool_filter_regex. Relies on a verified (not assumed) fastmcp behavior: mcp_config's underlying multi-server client prefixes every tool name with "{server_name}_" whenever 2+ servers are combined -- which is always true once any external server is attached (the built-in "kapelle" entry is always present too) -- so each server's own filter is anchored to its own prefix and can never leak into another's.

The same prefixing is user-visible for OpenHands: once any external server is attached, the built-in "kapelle" server's own tools show up in the model's tool list as kapelle_send_task, kapelle_ask_requester, etc, not their bare names -- roles/*.md's own prose says so, and canonical_tool_name (kapelle_worker.agent.mcp_servers) is the shared helper anywhere Kapelle's own code needs to recognize a built-in tool by name regardless of whether it got prefixed (status_mapping. ASK_REQUESTER_TOOL_NAME's own real gap this surfaced, backlog task fbfd3360's own follow-up).

Size the allow list for the smallest model that will use it

A real finding from tally-4's own full-loop run (2026-09-14): with GitHub's MCP server attached unfiltered (all 41 of its real tools), the coder role's own model (local-coder, a 27B model) spent its turns on search_repositories/list_commits against unrelated repositories instead of the task it was actually given -- a large, undifferentiated toolset distracts a small model into exploring instead of acting, even when every tool is individually legitimate. This is not primarily a safety concern (tools.allow/deny already existed for that) but a capability-budget one: a declaration's allow list should include only the tools the SMALLEST model in scope for that role/template actually needs for the task, not "everything this server can safely do." A frontier model attached to the same unfiltered server might navigate it fine; the allow list should still be sized for whichever model the template actually assigns that role, since a template's model: can change without the declaration changing with it.

roles/mcp/github.yaml is the worked example: narrowed from all 41 real tools down to seven (pull_request_read, list_pull_requests, get_file_contents, issue_read, add_issue_comment, add_comment_to_pending_review, pull_request_review_write) -- the read/comment surface a coder or reviewer actually needs for a read-only investigation or a review comment, with every search_* tool (the distraction itself), repository/branch/tag/release listing and creation, merging, and full issue edits (issue_write) all excluded.

Known gap

load_team_mcp_servers (worker) fetches curated team servers over the list_curated op above, briefly cached per team (OpenHandsAgentExecutor owns its own self._team_mcp_servers_cache, same TTL convention as skills.load_team_skills/executor.py's own role-config cache) -- an /mcp accept'd server reaches a real OpenHands run end to end. The Pydantic AI/Deep Agents lean runtimes deliberately do NOT get an NATS-wired team source (no NATS client field, no NATS/activity integration for anything today -- same documented scope limit this task's own step 4 attribution work already established for that comparison harness): a team-stored server resolves to [] for those two runtimes specifically, same as it always has, until that harness gets a NATS client of its own.