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_proposeMCP tool call): setssourceexplicitly (e.g."agent:payments/coder") and omitsuser. The controller skips its team-membership check whensourceis 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): omitssource, setsuserthe same way/team//memory//skillsdo. The controller checks team membership (_MEMBER_OR_ABOVE, same bar as/memory//skills) and derivessource = "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 forlist-- each element is oneMcpServerEntry.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 byopenhands_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.