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 mcp namespace)
  • 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's tools/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/list on each MCP server. Each discovered tool is registered as mcp.<server-key>.<tool-name>. The operator filters what gets registered via allow/deny patterns.
  • MCP servers are not subagents. They do not run in cages. They are capability providers the daemon proxies tool calls to. For stdio servers, the daemon spawns and supervises them as daemon children (not inside subagent cages). For sse servers, the daemon makes outbound HTTP/SSE connections gated by cage net.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 in local.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 any sampling/createMessage request.
  • 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's tools/list, registered under the reserved mcp.<server-key>.<tool-name> namespace, and dispatched through the existing ToolRegistry. 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) and sse (remote server over SSE + HTTP POST). Auth tiers: no-auth (MVP), header/bearer with secret_ref resolution (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) — stdio or sse. (streamable-http deferred.)
  • stdio fields: command (string[], required), cwd (project-relative path, optional), env (string map, optional).
  • sse fields: url (URI, required), headers (string map, optional, static headers only).
  • auth (optional) — see auth tiers below.
  • tools (optional) — filter block: enabled (boolean, default true), 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 null in project.local.yaml removes 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.
  • cwd is project-relative (same path rules as cage.fs[].path).
  • env is 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.allow on the calling agent does not directly gate the daemon's outbound connection — the daemon connects, not the agent. However, the sse server'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_ref from local.toml [secrets]. Missing secret → session refuses to start with a clear error naming the server and the missing ref.
  • type: bearer is shorthand for type: header with header_name: Authorization and the value prefixed with Bearer .
  • 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/createMessage requests. Allowing external code to trigger LLM calls is a trust boundary we do not cross (operator confirmed: "no").
  • MCP server version pinningmin_version support. Ignored (operator confirmed: "nah ignore").
  • streamable-http transport — 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's truncated field is set to true.

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, default false) — 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 by secret_ref at 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

  • mcp namespace reserved. ADR-0033 is amended: mcp joins 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 with McpServer, McpAuth, and McpToolFilter sub-schemas.
  • Local config extension. [secrets.<path>] section for MCP auth, [mcp.<server>] for per-server operator overrides.
  • ToolDefinition.source gains "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 stdio transport (child process supervision, JSON-RPC framing) and sse transport (connection management, reconnection). This is the largest implementation cost.
  • Result truncation. The daemon enforces a 64 KB default cap on MCP tool results.
  • sampling/createMessage rejection. The daemon explicitly rejects sampling requests from MCP servers.

What this forecloses

  • MCP servers cannot trigger LLM calls. The sampling/createMessage request 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_ref until 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/list means 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_ref keeps 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 + sse transports.
  • No-auth + header/bearer auth (secret_ref resolution).
  • Tool discovery (tools/list) and registration.
  • Per-agent enable via tools: block.
  • Result size truncation (64 KB default).
  • sampling/createMessage rejection.
  • 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-http transport.
  • 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: mcp domain, "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_ref resolution pattern
  • ADR-0028 — OAuth credential lifecycle (future MCP auth tier)
  • ADR-0033 — tool namespace convention (amended: mcp reserved)
  • Original discussion: design conversation with colleagues, 2026-06-15