ADR-0043: MCP as a tool source
- Status: Accepted
- Date: 2026-06-15
- Deciders: @karasu, with colleagues
- Supersedes: —
- Superseded by: —
- Amends: ADR-0033 (reserves the
mcpnamespace) - Relates to: ADR-0008 (plugin subprocess model — MCP is a distinct tool source, not a plugin), ADR-0011 (secrets never in the DSL), ADR-0028 (OAuth credential lifecycle)
Context
kaged's agent tooling has two tool sources today: built-in (daemon-core, high-performance, sandbox-mediated) and plugin (subprocess JSON-RPC, daemon-supervised, capability-scoped). Both are kaged-native contracts — you write to kaged's SDK or use kaged's built-in tools.
The Model Context Protocol (MCP) is a standardised protocol for exposing tools, resources, and prompts to LLM-based agents. A growing ecosystem of MCP servers exists: database query servers, cloud-resource managers, API adapters, search integrations. An operator who wants their kaged agent to call an existing MCP server should not need kaged to write a first-class integration for it. They should declare it in the DSL and have it work.
MCP servers expose tools (callable functions with JSON Schema parameters), resources (structured data), and prompts (templates). This ADR accepts tools only as the MVP surface. Resources and prompts are deferred to a later amendment (operator confirmed: "v2").
The constraint set:
- MCP is a standard protocol, not a kaged contract. Unlike plugins (which implement kaged's JSON-RPC method schema), MCP servers implement the MCP specification. The daemon adapts between kaged's
ToolDefinition/ dispatch model and MCP'stools/list+tools/call. No MCP server author needs to know about kaged. - Two transport types in MVP.
stdio— the daemon spawns the MCP server as a child process, communicates over stdin/stdout JSON-RPC.sse— the daemon connects to a remote MCP server over SSE + HTTP POST. A third transport (streamable-http) is deferred. - Tool discovery is dynamic. At session-start (or first use), the daemon calls
tools/liston each MCP server. Each discovered tool is registered asmcp.<server-key>.<tool-name>. The operator filters what gets registered viaallow/denypatterns. - MCP servers are not subagents. They do not run in cages. They are capability providers the daemon proxies tool calls to. For
stdioservers, the daemon spawns and supervises them as daemon children (not inside subagent cages). Forsseservers, the daemon makes outbound HTTP/SSE connections gated by cagenet.allow. - Secrets follow the portability principle (ADR-0011). The DSL declares what auth a server needs (
secret_ref: api_keys.example). The concrete secret value lives inlocal.toml [secrets]. Secrets never travel with the project. - MCP sampling is not supported. MCP servers can request the client (kaged) to perform LLM sampling (
sampling/createMessage). This is a fundamental trust boundary: allowing MCP servers to trigger LLM calls means external code can spend the operator's tokens and influence agent reasoning. The answer is no (operator confirmed). The schema accommodates the field for future opt-in; the daemon rejects anysampling/createMessagerequest. - Result size must be bounded. MCP tool results can be arbitrarily large. The daemon enforces a configurable cap (daemon-level, default 64 KB) and truncates with a marker (operator confirmed).
Decision
kaged accepts MCP as a third tool source alongside built-in and plugin. MCP tools are declared in the project DSL under a top-level
mcp:block, discovered at runtime via MCP'stools/list, registered under the reservedmcp.<server-key>.<tool-name>namespace, and dispatched through the existingToolRegistry. The daemon proxies all MCP tool calls — it is the MCP client, not the agent. Two transports ship in MVP:stdio(daemon-spawned child process) andsse(remote server over SSE + HTTP POST). Auth tiers: no-auth (MVP), header/bearer withsecret_refresolution (MVP), OAuth (future). MCP resources, prompts, and sampling are not supported.
1. DSL surface
A new top-level mcp: block in the project DSL declares MCP servers:
mcp:
github:
transport: sse
url: https://api.example.com/mcp
auth:
type: bearer
secret_ref: api_keys.example
headers:
X-Feature-Flag: mcp-beta
tools:
allow: [repo.*, issue.*]
deny: [repo.delete]
database:
transport: stdio
command: ["bun", "run", "tools/mcp-db-server.ts"]
cwd: ./tools
env:
DB_PATH: ./data/app.db
tools:
enabled: true
- Keys are server-key slugs (
^[a-z][a-z0-9_-]{0,30}[a-z0-9]$). The key forms part of the tool namespace:mcp.<key>.<tool>. transport(required) —stdioorsse. (streamable-httpdeferred.)stdiofields:command(string[], required),cwd(project-relative path, optional),env(string map, optional).ssefields:url(URI, required),headers(string map, optional, static headers only).auth(optional) — see auth tiers below.tools(optional) — filter block:enabled(boolean, defaulttrue),allow(glob patterns),deny(glob patterns). Applied at registration time; only matching tools are registered.- Max servers: 16 per project (
maxProperties: 16). - Nullification: setting a key to
nullinproject.local.yamlremoves the server (ADR-0015).
2. Tool namespace and registry integration
All MCP tools live under the mcp namespace: mcp.<server-key>.<tool-name>. The mcp namespace is reserved per ADR-0033 amendment. Built-in tools and plugins cannot claim it.
The ToolDefinition type gains a new source value and MCP-specific fields:
interface ToolDefinition {
name: string; // "mcp.github.issue.create"
namespace: string; // "mcp"
description: string; // from MCP tool metadata
parameters: JSONSchema; // from MCP tool's inputSchema
returns: JSONSchema; // from MCP tool's outputSchema (or empty)
requires: ToolRequirement[];
source: "builtin" | "plugin" | "mcp";
mcp_server?: string; // DSL key of the MCP server
mcp_tool_name?: string; // tool name as the MCP server knows it
}
Registration: At session-start, the daemon calls tools/list on each configured MCP server. Each tool is registered as mcp.<key>.<tool> with source: "mcp". The tools.allow/deny patterns filter what gets registered. Registration failures (server unreachable, tools/list error) mark the server as failed; the session may still start with other MCP servers active.
Dispatch: Agent calls mcp.github.issue.create → registry resolves to source: "mcp" → daemon proxies to the MCP server's tools/call endpoint → result returned through the standard tool-result path (with size truncation applied).
Per-agent enablement: MCP tools follow the existing per-agent tools: resolution (ADR-0022). An agent opts in via its tools: block:
primary:
tools:
"mcp.github.issue.*": { enabled: true }
"mcp.database": { enabled: true } # entire server's tools
subagents:
researcher:
tools:
"mcp.github.issue.list": { enabled: true } # read-only subset
MCP tools are not root-only. Any agent can be granted MCP tools via its tools: block, subject to cage filtering.
3. Transports
stdio
Local MCP servers. The daemon spawns the server as a child process at session-start (or first use) and communicates over stdin/stdout JSON-RPC.
- Lifecycle: Spawn at first tool call for the server. Keep alive for the session. Restart on crash with exponential backoff (1s, 2s, 4s, 8s, capped at 60s). After 5 consecutive failures, mark as failed; operator can re-enable.
- Process model: Daemon child, NOT inside a subagent cage. The daemon is the MCP client. Same supervision pattern as plugin subprocesses (ADR-0008), but a different protocol.
cwdis project-relative (same path rules ascage.fs[].path).envis merged on top of the daemon's environment (additive).
sse
Remote MCP servers. The daemon establishes a connection at session-start or first call.
- Connection: SSE for server-to-client messages, HTTP POST for client-to-server. Reconnects on disconnect with backoff.
- Cage interaction: The cage
net.allowon the calling agent does not directly gate the daemon's outbound connection — the daemon connects, not the agent. However, thesseserver's URL hostname must be declared in the project DSL (portable, visible) and the operator's local config can disable any server. This is the same trust model as LLM provider connections: the daemon brokers, the DSL declares.
4. Auth tiers
Tier 1: No auth (MVP)
mcp:
local:
transport: stdio
command: ["bun", "run", "tools/server.ts"]
No auth block. The MCP server handles auth internally or needs none.
Tier 2: Header / bearer with secret_ref (MVP)
mcp:
api:
transport: sse
url: https://api.example.com/mcp
auth:
type: header # or: bearer
header_name: X-API-Key # default: Authorization
secret_ref: api_keys.example
# local.toml — operator's secrets (never in the DSL)
[secrets.api_keys]
example = "sk-live-abc123..."
- Resolution: At session-start, the daemon resolves
secret_reffromlocal.toml [secrets]. Missing secret → session refuses to start with a clear error naming the server and the missing ref. type: beareris shorthand fortype: headerwithheader_name: Authorizationand the value prefixed withBearer.- The daemon attaches the resolved header to every request to the MCP server.
Tier 3: OAuth (future, not MVP)
auth:
type: oauth
provider: github
scopes: [repo, read:org]
Deferred. Requires OAuth provider registry, token storage/encryption, UI integration, refresh handling. The schema accommodates the shape; implementation lands in a later phase. See ADR-0028 for the credential lifecycle pattern this would follow.
5. What is explicitly NOT supported
- MCP resources — structured data exposed by MCP servers. Deferred to v2 (operator confirmed).
- MCP prompts — prompt templates exposed by MCP servers. Deferred to v2.
- MCP sampling — MCP servers requesting the daemon to perform LLM calls. Not supported. The daemon rejects
sampling/createMessagerequests. Allowing external code to trigger LLM calls is a trust boundary we do not cross (operator confirmed: "no"). - MCP server version pinning —
min_versionsupport. Ignored (operator confirmed: "nah ignore"). streamable-httptransport — deferred to Phase 2.
6. Result size limits
The daemon enforces a configurable result-size cap on all MCP tool results.
- Default: 64 KB.
- Configurable at: daemon operational config (
config.toml), not per-server or per-tool. One knob for all MCP servers. - Behaviour: Results exceeding the cap are truncated to the limit and appended with a truncation marker (e.g.
\n[... truncated at 65536 bytes]). The tool result'struncatedfield is set totrue.
7. Local config surface
# local.toml — operator overrides
[mcp.github]
disabled = false # operator can disable any project MCP server
timeout_ms = 30000 # per-call timeout override
[mcp.database]
disabled = true # operator disables a server they don't want
# Secrets (never in the DSL)
[secrets.api_keys]
example = "sk-live-abc123..."
github = "ghp_xxxx..."
disabled(boolean, defaultfalse) — the operator can disable any MCP server declared in the project DSL. Disabled servers are not contacted; their tools are not registered.timeout_ms(integer, optional) — per-call timeout for this server. Default: daemon-level default (30s).- Secrets under
[secrets.<path>]are resolved bysecret_refat session-start.
8. Relationship to plugins (ADR-0008)
MCP is not a plugin transport. Plugins and MCP are distinct tool sources with different contracts:
| Plugins (ADR-0008) | MCP | |
|---|---|---|
| Protocol | kaged JSON-RPC (kaged-defined methods) | MCP specification (tools/list, tools/call) |
| Discovery | Static — methods declared in manifest | Dynamic — tools/list at runtime |
| SDK | @kaged/plugin-sdk required |
None — any MCP server works |
| Sandbox | bwrap with capability allowlist | Daemon child (stdio) or remote (sse) |
| Storage | Brokered SQLite slice | None (MCP servers manage their own state) |
| Lifecycle hooks | on_session_start, pre_compact, etc. |
None |
| Tool naming | <plugin-name>.<method> |
mcp.<server-key>.<tool> |
An MCP server could be wrapped as a kaged plugin (a plugin that translates between kaged JSON-RPC and MCP), but that adds a translation layer for no benefit. MCP gets first-class support because the protocol is standardised and the ecosystem exists.
Consequences
What this commits us to
mcpnamespace reserved. ADR-0033 is amended:mcpjoins the reserved namespace list. Built-in tools and plugins cannot use it.- DSL schema extension. A new top-level
mcp:block in the project DSL withMcpServer,McpAuth, andMcpToolFiltersub-schemas. - Local config extension.
[secrets.<path>]section for MCP auth,[mcp.<server>]for per-server operator overrides. ToolDefinition.sourcegains"mcp". The tool registry and dispatch path handle the new source.- An MCP client in the daemon. The daemon gains an MCP client subsystem that handles
stdiotransport (child process supervision, JSON-RPC framing) andssetransport (connection management, reconnection). This is the largest implementation cost. - Result truncation. The daemon enforces a 64 KB default cap on MCP tool results.
sampling/createMessagerejection. The daemon explicitly rejects sampling requests from MCP servers.
What this forecloses
- MCP servers cannot trigger LLM calls. The
sampling/createMessagerequest is rejected. MCP servers that require sampling to function will not work with kaged. - No MCP resources or prompts in v1. Operators who need MCP resources as injected context or MCP prompts as system-prompt alternatives must wait for a v2 amendment.
- No per-tool result size limits. The cap is daemon-level, not per-server or per-tool. (Future: per-server overrides in local config.)
- No automatic OAuth. MCP servers requiring OAuth flows (GitHub, Google Workspace) must use static tokens via
secret_refuntil the OAuth tier ships.
What becomes easier
- Connecting to the MCP ecosystem. Any existing MCP server works with kaged by declaring it in the DSL. No SDK, no first-class integration, no plugin authoring.
- Tool surface extensibility. Operators can add arbitrary tool capabilities (database queries, cloud APIs, search) without kaged building or maintaining integrations.
- Standardised tool discovery.
tools/listmeans the daemon learns the tool surface at runtime — no manifest maintenance, no schema drift between declaration and reality. - Consistent agent experience. MCP tools appear identically to built-in and plugin tools in the agent's tool catalog. The agent does not know or care whether a tool is built-in, plugin-provided, or MCP-proxied.
What becomes harder
- MCP client complexity. The daemon now manages two IPC protocols (kaged JSON-RPC for plugins, MCP for MCP servers) plus two transport types within MCP. Each has its own framing, error handling, and lifecycle.
- Security surface. Remote MCP servers (
sse) are external endpoints the daemon connects to. The URL is in the DSL (portable, visible), but the operator must trust the server.secret_refkeeps credentials local, but the server itself receives whatever data the agent sends it in tool parameters. - Debugging. MCP tool failures can originate in the MCP server, the transport, the daemon's proxy layer, or the tool registry. Each failure mode needs distinct diagnostics.
Alternatives considered
Alternative A — MCP as a plugin transport
Why tempting: Reuse ADR-0008's subprocess + JSON-RPC + supervision infrastructure. Write a "MCP adapter plugin" that translates between kaged JSON-RPC and MCP. No new daemon subsystem.
Why rejected: MCP's protocol is fundamentally different from kaged's plugin contract. MCP has dynamic tool discovery (tools/list), bidirectional notifications, and its own initialization handshake. Forcing MCP through the plugin contract means either (a) the adapter plugin must pre-declare all MCP tools in its manifest (defeating dynamic discovery) or (b) the plugin host must be extended to support dynamic method registration (which is just reinventing MCP support inside the plugin host, worse). The translation layer adds latency and a failure point for no benefit. MCP gets first-class support because it is a standardised protocol with an existing ecosystem.
Alternative B — MCP only via sse, no stdio
Why tempting: Remote MCP servers are the common case (API adapters, cloud services). stdio adds child-process supervision complexity. Dropping it simplifies the daemon.
Why rejected: Local MCP servers are valuable for operators who run their own tool servers (database adapters, custom integrations). stdio is the MCP community's default transport and is lower-latency than sse for local servers. The supervision pattern is well-understood (ADR-0008 already solves it for plugins). Dropping stdio would push operators toward running a local HTTP server for their MCP server, which is needless operational overhead.
Alternative C — Defer MCP entirely to v2
Why tempting: The tool ecosystem (built-in + plugin) already covers many use cases. MCP adds a significant implementation surface.
Why rejected: The MCP ecosystem is growing rapidly. Operators will expect to connect their existing MCP servers to kaged. Shipping without MCP support means operators either (a) write kaged plugins to wrap their MCP servers (translation overhead, maintenance burden) or (b) use a different agent platform. MCP is a standardised protocol — supporting it is a connectivity decision, not an architecture decision.
Phasing
Phase 1 (MVP)
stdio+ssetransports.- No-auth + header/bearer auth (
secret_refresolution). - Tool discovery (
tools/list) and registration. - Per-agent enable via
tools:block. - Result size truncation (64 KB default).
sampling/createMessagerejection.- Basic error handling (server unreachable, tool call failure).
Phase 2
- OAuth auth flow (token lifecycle, refresh, provider registry).
- Health monitoring + UI status for MCP servers.
- Per-server timeout/retry configuration.
streamable-httptransport.- mTLS auth.
Phase 3
- MCP resources as injected context.
- MCP prompts as system-prompt alternatives.
Spec amendments required
| # | File | Change |
|---|---|---|
| 1 | docs/specs/agent-tooling.md |
Add mcp to the namespace table. Add source: "mcp" to ToolDefinition. Document MCP tool dispatch path and result truncation. |
| 2 | docs/specs/project-dsl.md |
Add mcp: top-level block. Document McpServer, McpAuth, McpToolFilter schemas. Add to Appendix A JSON Schema. |
| 3 | docs/specs/local-config.md |
Add [secrets.<path>] section. Add [mcp.<server>] operator override section. |
Existing ADRs amended
- ADR-0033 — The reserved namespace list gains
mcp. The namespace directory gains a row:mcpdomain, "External protocol adapters (MCP)", tools discovered dynamically.
References
- Issue #26 — MCP tooling concept and operator decisions
- Model Context Protocol specification
- ADR-0008 — plugin subprocess model (MCP is distinct; comparison in §8)
- ADR-0011 — secrets never in the DSL;
secret_refresolution pattern - ADR-0028 — OAuth credential lifecycle (future MCP auth tier)
- ADR-0033 — tool namespace convention (amended:
mcpreserved) - Original discussion: design conversation with colleagues, 2026-06-15