Documentation — MCP — tools & events
How it works

MCP — tools & events

agentd has no opinions about what it can do — it ships almost no task tools of its own. Everything an agent can touch arrives over the Model Context Protocol (MCP, target spec 2025-11-25): agentd is an MCP client, and the tools and resources of the servers you declare become the agent's entire action space.

The other direction — a parent agent, a peer, or an operator driving this agent — is A2A over HTTPS. One protocol out, one protocol in: agents nest and drive each other with no special-case wire.


1. agentd as MCP client

1.1 There are no built-in tools

agentd ships no task tools of its own and runs no local code. Every capability — read a file, query an API, run a search — is a tool on some MCP server you declare. agentd discovers them with tools/list and invokes them with tools/call. If you declare zero servers, agentd's task toolbox is empty (its built-in tools are its own control and bookkeeping primitives — subagent.*, workflow.*, memory.*, artifact.*, plan.*, skills.*, instruction.*, message.send, ask_human, think, sleep, await, context.compact, finish and status — which act on the agent, its own conversations, its own model or the operator, never on the world). Four more are contracts without a built-in implementation — knowledge.*, search.*, code.run and exec — which resolve only when an operator maps them to an MCP server (and exec additionally needs --features exec plus security.exec.enabled). One more, resource.read, joins the catalogue only when there is something to read: an MCP server's resource, or an agentd:// self-resource such as an async child's completion.

This is deliberate: the action space is configuration, not code. Swapping what an agent can do never means rebuilding agentd.

1.2 Declaring servers — --mcp name=<endpoint>

You declare each MCP server with --mcp name=<endpoint>, repeatable. Each names a remote MCP endpoint reached over Streamable HTTP — agentd connects to it and speaks JSON-RPC over HTTP(S); it spawns no subprocess and runs no local code.

agentd \
  --instruction "Summarize the open TODOs under /work and write a digest" \
  --intelligence https://gw.example/v1 \
  --mcp fs=https://mcp-fs.internal/mcp \
  --mcp http=https://mcp-http.internal/mcp

The part after = is the endpoint — https://host[:port][/path], or a loopback http:// for a same-host dev sidecar. Per-server auth/framing headers (e.g. Authorization: Bearer {{secret:…}}) are declared secret-free in the config file's mcp.servers[].headers and resolved at connect time (never inlined or logged).

The endpoint is trusted config — it is never built from model- or server-controlled strings. Declare servers from your deployment config, not from agentd output.

Multiple servers coexist; tool names are server-qualified internally so two servers can both expose a search tool without colliding. A --mcp with an empty name or a non-https/non-loopback-http endpoint is rejected at startup (exit 2) before any side effect.

--mcp is sugar for one entry of the mcp.servers config path, so the same list is equally a config-file block, a --mcp-servers flag, or AGENTD_MCP_SERVERS. The file form is the richer one — it carries per-server headers, auth, tags, a timeout, and ns (a tool-namespace prefix, so tools arrive as ns.tool):

mcp:
  default_timeout: 60s
  servers:
    - { name: fs,   endpoint: https://mcp-fs.internal/mcp }
    - { name: http, endpoint: https://mcp-http.internal/mcp, ns: web, timeout: 30s }

1.3 The handshake and capability negotiation

On connect, before anything else, agentd runs the MCP lifecycle. It pins protocolVersion: "2025-11-25" and declares client capabilities sparingly. The handshake below is a supervisor boot connection — the one the process dials while assembling the tool catalogue at startup and again on reload — and it declares none at all:

// agentd → server
{ "jsonrpc":"2.0","id":1,"method":"initialize","params":{
    "protocolVersion":"2025-11-25",
    "capabilities":{},                                    // a boot connection declares none
    "clientInfo":{"name":"agentd","version":"1.15.0",
                  "title":"<instance>"}                   // build version; title = the instance name
}}
// server → agentd
{ "jsonrpc":"2.0","id":1,"result":{
    "protocolVersion":"2025-11-25",
    "capabilities":{
      "resources":{"subscribe":true,"listChanged":true},
      "tools":{"listChanged":true}
    },
    "serverInfo":{"name":"mcp-server-fs","version":"…"},
    "instructions":"…"                                   // optional; folded into the prompt
}}
// agentd → server
{ "jsonrpc":"2.0","method":"notifications/initialized" }

A connection the agent loop holds — a turn worker's or a subagent's — sends "capabilities":{"elicitation":{}} instead, and no title: the workload label is stamped once, in the supervisor process, and the spawn payload that re-execs a child carries no instance name, so a child's clientInfo.title is absent.

Why so nearly empty? You only declare a client capability when you intend to service it, and agentd services one: elicitation. A server's elicitation/create becomes an ask_human gate — the question is rendered in every attached display client, the answer is shaped against the server's requestedSchema, and an answer that cannot be made to conform, or that nobody gives within 300s, comes back to the server as cancel. It offers no roots, sampling or tasks. That is the minimal interop posture and the smallest injection surface, and it is self-enforcing on the wire: apart from elicitation, agentd reads notifications off the server→client stream and nothing else. Any other server→client request has no declared capability behind it, so it is dropped rather than answered — there is no roots/list to leak a filesystem scope and no sampling/createMessage to turn the agent's model into someone else's.

Version negotiation. agentd offers 2025-11-25 and accepts a downgrade to 2025-06-18, 2025-03-26, or 2024-11-05 where the feature use overlaps (e.g. structured tool output requires ≥ 2025-06-18). A version it cannot speak, or a handshake that doesn't finish inside that server's request timeout (mcp.servers[].timeout, else mcp.default_timeout, default 60s), is a connect failure. The negotiated capability set is then frozen and gates every subsequent call: agentd never sends resources/subscribe to a server that didn't advertise resources.subscribe; it degrades instead.

A server that fails its handshake is logged (mcp.connect.fail) and simply omitted from the catalogue — its tools are unavailable, the rest of the run goes on. The exception is the server backing the durable store (store.mcp.server): agentd will not run without its state, so that one failing exits 6.

1.4 Tools: list and call

tools/list is drained across all pages — agentd follows nextCursor to exhaustion, and each page is bounded by the server's request timeout (cursors are opaque; agentd never interprets one).

// agentd → server
{ "jsonrpc":"2.0","id":2,"method":"tools/call",
  "params":{ "name":"get_weather", "arguments":{"location":"NYC"},
             "_meta":{ "agent/run_id":"<run_id>", "agent/instance":"<instance>",
                       "traceparent":"<w3c-traceparent>" } } }
// server → agentd  (success — note isError lives INSIDE result)
{ "jsonrpc":"2.0","id":2,"result":{
    "content":[ { "type":"text","text":"22.5°C" } ],
    "isError":false,
    "structuredContent":{ "temperature":22.5 }     // iff the tool declared an outputSchema
}}

The run id, the instance and the trace context flow into every call's _meta, so a backing service can correlate — and dedupe a retry — end to end.

The load-bearing distinction — isError vs JSON-RPC error:

Wire shapeMeaningWhat agentd does
result.isError == truetool ran and reported a failure (a successful JSON-RPC response)feed content[] back to the model as an observation; it self-corrects; consumes a step
top-level JSON-RPC errorprotocol/transport fault (unknown tool, bad params, server crash)classify per the retry/abort policy — not handed to the model as a normal observation

A tool saying "file not found" is an observation the model reasons about. A server saying "I have no such tool" is a protocol error. Conflating them is a classic agent bug; agentd keeps them strictly separate.

Tool descriptions and annotations are untrusted. They are server-controlled text (the "tool poisoning" surface). agentd surfaces and logs them for operator audit but never auto-trusts them. See security.md for how untrusted text is contained.

On notifications/tools/list_changed (only if the server advertised tools.listChanged) agentd records the change as mcp.tools_changed. The tool catalogue itself is rebuilt from a fresh tools/list per server on the next config reload — SIGHUP or lifecycle.watch_config (configuration.md §11) — which is also where a re-handshake picks up an endpoint or header change.

1.5 Resources: list vs read

Resources are the agent's context surface, split into two deliberately distinct operations:

  • resources/list = awareness. A compact catalogue — each resource's URI and a short label, never bodies — collected from every connected server (first owner wins a duplicate URI, and the list is capped) and injected into the agent's prompt so it knows what exists.
  • resources/read = attention. The actual body, pulled on demand through the built-in resource.read tool.

resources/read always returns a contents array (one URI may yield several items, e.g. a directory listing), text in text, binary base64 in blob:

{ "jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"file:///work/todo.md"} }
{ "jsonrpc":"2.0","id":3,"result":{ "contents":[
    { "uri":"file:///work/todo.md","mimeType":"text/markdown","text":"- ship M2\n- …" }
]}}

A missing resource returns -32002 with data.uri — surfaced as an observation, not a transport abort. resources/templates/list is available on the client but informational only: templates are not subscribable; agentd reacts to concrete URIs only.

1.6 Reactivity: the notify-then-read subscription model

This is how agentd wakes on external change. The model has one non-obvious but load-bearing property: the update notification carries no payload.

// agentd → server  (only if caps.resources.subscribe; one CONCRETE uri, never a template)
{ "jsonrpc":"2.0","id":4,"method":"resources/subscribe","params":{"uri":"file:///work/inbox"} }
{ "jsonrpc":"2.0","id":4,"result":{} }

// later — server → agentd
{ "jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"file:///work/inbox"} }

The notification says only "file:///work/inbox changed" — no diff, no new content. So agentd does notify-then-read: on wake it issues a fresh resources/read to learn the current state. Two consequences fall out of this:

  1. It's two round-trips, and the read can race a subsequent update. agentd's contract is at-least-once delivery + convergence by re-reading current state — redelivery is harmless because you always act on what the resource is now, not on a stale diff. (Debounce, coalescing and filtering of these wakes are options on the subscribe start node — debounce_ms, coalesce, filter; see workflows.md.)
  2. Subscriptions are (re-)armed whenever the workflow that owns them is armed — at startup, and again after a config reload — so a restart restores every watched URI. Underneath, the notification stream reconnects on its own after a transient drop; a server with no push channel at all leaves the client pull-only rather than failing.

Two distinct mechanisms — never conflated:

TriggerCapability neededSubscribe callNotificationPayload
a specific item changedresources.subscriberesources/subscribe{uri} per URInotifications/resources/updated{uri} (+ optional title)
the set of resources changedresources.listChangednone (capability-implied)notifications/resources/list_changednone
the set of tools changedtools.listChangednonenotifications/tools/list_changednone

You wire a subscription to a run with a subscribe start node in a workflow:

config_version: "1"
intelligence: { endpoints: https://gw.example/v1, model: my-model }
store: { kind: mcp, mcp: { server: state } }
mcp: { servers: [ { name: fs, endpoint: https://mcp-fs.internal/mcp }, { name: state, endpoint: https://mcp-state.internal/mcp } ] }
workflows:
  - name: triage
    steps:
      s: { kind: subscribe, server: fs, uri: "file:///work/inbox" }
      w: { kind: agent, depends_on: [s], instruction: "Triage new items in the inbox." }
      f: { kind: finish, depends_on: [w] }
lifecycle: { run_until: drained }

The runtime issues resources/subscribe for the URI, then idles and fires a run on each update (notify-then-read).

Reactivity rides Streamable HTTP. Subscriptions are resources/subscribe against the owning MCP server; the client holds the SSE stream open and processes pushed notifications/resources/updated (notify-then-read).

1.7 Liveness and lifecycle

Every request is bounded, though not all by the same clock. The supervisor's own connections — the boot handshake, tools/list — honour that server's timeout (mcp.servers[].timeout, else mcp.default_timeout, default 60s), and the registry-routed calls the runtime dispatches — a workflow's tool: step, its memory.*/artifact.*/knowledge.*/search.* steps — take mcp.default_timeout directly. A workflow's own mcp.tool step is bounded by that step's timeout instead (else limits.step_timeout, default 600s), because it names its server itself rather than routing through the registry. The in-loop tools/calls a turn worker or a subagent makes are bounded by a fixed 60s: the spawn payload carries no per-server timeout, so neither dial reaches the child. Either way a wedged server cannot hang the loop: the call fails, the failure becomes an observation, and the run carries on. Its ping method is available as an explicit liveness round-trip.

Because agentd spawns no process for an MCP server, there is no child to signal or reap — closing the HTTP connection is the shutdown. The notification thread stops with it, and the whole drain counts inside lifecycle.drain_timeout (default 25s).


Who implements the protocol

Outbound MCP is rmcp, the official Rust SDK maintained alongside the specification. It owns the handshake, the request and notification types, capability negotiation, the streaming rules, the error mapping and the version table. agentd tracks MCP by upgrading a dependency rather than by re-reading a document.

The argument for depending on the SDK rather than hand-rolling the wire is not that hand-rolled code is buggier in general — it is that a protocol implemented from your own reading of a specification fails silently, in the peer. Your tests encode the same reading as your code, so they agree with it, and the disagreement only shows up against someone else's server.

The socket stays agentd's

The SDK's transport is a trait, and agentd implements it over its own HTTP connection. So adopting the SDK cost none of the things that only agentd knows how to do:

  • AAuth request signing (RFC 9421), including the challenge/re-sign loop
  • OAuth token refresh through the signer seam
  • AWS SigV4, computed per request
  • SPIFFE X.509-SVID mutual TLS
  • the SSRF guard on every dial

There is no split fleet and no fallback path: every server goes through the SDK, and every server keeps its credentials.

Which revision agentd speaks

Whatever the SDK speaks. rmcp pins its LATEST at 2025-11-25 even though the newer constant exists — that is upstream saying what it is ready to speak, and overriding it would mean asking servers for a dialect the SDK may not fully implement. agentd gains the stateless revision on the release that promotes it, with no change here. The subscription mechanism follows the same rule: resources/subscribe at an older revision, subscriptions/listen at a stateless one, chosen from what was actually negotiated.

What agentd still owns

The reactive half — that a server's notifications/resources/updated becomes a workflow wake — is agentd's, and it is wired to the SDK's notification hooks. It is worth naming because it is the thing that breaks quietly: a client that receives a notification and drops it leaves the agent idle forever, with nothing in any log to say why.

2. The other direction

MCP is how agentd reaches out for capability. How something reaches in — a peer agent, an operator, the TUI — is a different protocol on a different listener: see a2a.md. Keeping them apart matters when reasoning about trust: an MCP server is a dependency you chose, while an A2A caller is a stranger you authenticate.

See also

  • a2a.md — the external channel: conversations, principals, commands.
  • agent-loop.md — who calls these tools, and when a run ends.
  • workflows.md and node-registry.md — the subscribe start node and every other trigger and step.
  • subagents.md — how a child process inherits a narrowed slice of this tool catalogue.
  • configuration.md — the full mcp: block, headers, auth and the durable store.
  • security.md — SSRF, tool poisoning and the trifecta gate.