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 shape | Meaning | What agentd does |
|---|---|---|
result.isError == true | tool 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 error | protocol/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-inresource.readtool.
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:
- 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
subscribestart node —debounce_ms,coalesce,filter; see workflows.md.) - 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:
| Trigger | Capability needed | Subscribe call | Notification | Payload |
|---|---|---|---|---|
| a specific item changed | resources.subscribe | resources/subscribe{uri} per URI | notifications/resources/updated | {uri} (+ optional title) |
| the set of resources changed | resources.listChanged | none (capability-implied) | notifications/resources/list_changed | none |
| the set of tools changed | tools.listChanged | none | notifications/tools/list_changed | none |
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/subscribeagainst the owning MCP server; the client holds the SSE stream open and processes pushednotifications/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.mdandnode-registry.md— thesubscribestart node and every other trigger and step.subagents.md— how a child process inherits a narrowed slice of this tool catalogue.configuration.md— the fullmcp:block, headers, auth and the durable store.security.md— SSRF, tool poisoning and the trifecta gate.