ADR-187: Attend MCP server mode: outbound and queries as typed tools, inbound stays on Monitor and the Stop hook¶
Context¶
An agent session reaches attend through one surface today: CLI verbs run from the Bash tool. Inbound messages arrive through two conduits, the Monitor-hosted attend run sensor loop for an idle session and the Stop-hook drain attend inbox --drain for an active one (ADR-172). The skill and the way both state that the CLI is the contract (ADR-124, ADR-136), and the same messaging guidance is held in lockstep across three sources: skills/attend/SKILL.md, the sensor-disclosure reheat messaging.md, and the attend way.
The lockstep exists because agents misuse the surface without repetition. Issues #536 and #538 name the classes seen in transcripts:
- running
attend runfrom Bash, which blocks the tool call and discards notifications; - reading or editing under
~/.cache/attend/and~/.config/attend/to work around an unclear verb; - shell metacharacters in message bodies, which the "always double-quote" rule and the glob-expansion fence in
cmd/send.rsexist to catch; - envelope flags (
--to,--channel,--re) that have to be remembered and ordered before the trailing message.
The transcript audit behind #538 found that guidance carried in a tool's own description had the highest adherence of any placement. The three prose sources fire at session start, at reheat, and on the attend command trigger; a tool description is present whenever the tool is callable.
The MCP protocol revision of 2026-07-28 sets the boundary. The initialize handshake and sessions are removed; sampling, roots, and logging are deprecated; server-initiated requests are replaced by multi-round-trip responses to client requests. The one unsolicited server-to-client channel is subscriptions/listen, which carries list-change and resource-change notifications to the host. No path in the protocol places content into the model's turn. ADR-172 listed "MCP push as the delivery mechanism" as a possibility to revisit; the revision closes it.
Two adjacent threads shape the parameters. Issues #532 and #533 add structured envelope fields to every signal (from_kind, from_id, on_behalf_of, to), and the design note for them is merged at docs/architecture/attend/ADR-401-attend-envelope-fields-sender-kind-principal-and-addressee-on-every-signal.md. ADR-181 defines guard hooks as a PreToolUse class that ships deactivated, with default activation reserved for a further ADR under ADR-162's bar. The shipped settings.json allows Bash(attend:*) as one entry in the ADR-169 operational baseline, so an operator has no way today to allow queries while gating sends.
Decision¶
Attend runs as two roles. The perceiving role, the Monitor-hosted sensor loop and the Stop-hook drain, owns every piece of mutable session state. The acting role, the CLI message verbs and the MCP server, owns none and is a client of it. Outbound and queries become typed tools on an MCP server that shares one library with the CLI.
-
The split is forced by the protocol. Under the 2026-07-28 revision a server can answer a client request and can notify the host of a changed list or resource. Neither reaches the model mid-turn or wakes an idle session. The two conduits ADR-172 fixed therefore remain the only inbound paths, unchanged in mechanism and in contract. The MCP server carries the direction the protocol supports: the model calling out.
-
Tool set. The server exposes
send,reply,peers,inbox,status,join, andleave, named by the chat idiom ADR-173 chose.run,inbox --drain,whoami --machine,tune,sensors,config,permissions, andcleanupstay CLI; they are session infrastructure invoked by Monitor, by hooks, or by the operator (ADR-173 item 5).keepwarm(ADR-182),scene,scenes,channels, anddissolvestay CLI until use shows a reason to add them, and adding one follows item 3.chatstays CLI-only; it is a TUI with its own stdout. The deprecatedfocusalias of--channel(ADR-173) resolves incli.rsbefore the library boundary, so the library and the tool schema know onlychannel. -
One library, two frontends. The verb bodies now under
tools/attend/src/cmd/move into a library target with typed request and response structs.cli.rsand the MCP server are thin frontends over it. Each verb carries one description string, and three renderings derive from it: clap help, the generateddocs/cli/attend.md, and the MCP tool schema. A test asserts the three agree. The JSON document a verb emits under--json(ADR-185) and the structured content of its tool result are one shape. -
Shared state for
replyandinbox.replythreads to the recordsensor_peers::last_inboundwrites for the session.inboxlists every signal in the scan directories, as the CLIinboxlist does today; only the drain consults the seen-setattend-stateholds under ADR-172's merge semantics, and only the drain marks or filters by it. The MCP frontend reads the last-inbound record through the library and keeps no store of its own. The MCPinboxtool is read-only. -
Transport, and every call is a fresh invocation. The server runs as
attend mcpover stdio, launched by the client once per session. It is resident, and that is the one property it does not share with the CLI verbs, so each tool call behaves as one CLI invocation would: it derives the session id by the ADR-171 ancestry walk, verifies that a registration for that id exists in the instance registry the sensor loop maintains, adopts the registered tuple (persona and ordinal), reads the last-inbound record and channel membership from disk, performs the verb, and holds nothing in memory for the next call. No memoized tuple, no inbox cursor, no cached membership. The sensor loop can restart under Monitor's cap and re-register, and the next call sees the new registration.statusreports the derived id, the registration found for it, and theresolvedflag as read at call time. When ancestry resolves to a Claude session and no registration exists for it, because the loop has not registered yet or is not running,sendandreplyrefuse with a cause namingattend run, matching the drain's refusal in ADR-172 item 4, so a persona the loop never allocated is never written to the bus. -
Permissions are per tool. Each tool has its own permission name:
mcp__attend__send,mcp__attend__reply,mcp__attend__peers,mcp__attend__inbox,mcp__attend__status,mcp__attend__join,mcp__attend__leave. The shippedsettings.jsonallows all seven, which preserves the autonomy the skill grants today underBash(attend:*). The per-tool names are the operator's control over the MCP path: removingmcp__attend__sendputs that tool behind a prompt for a session running an approval rule. While both frontends ship,Bash(attend:*)stays allowed for the CLI frontend, so the per-tool entry alone gates the MCP path only; a hard outbound gate also needsBash(attend send*)andBash(attend reply*)inpermissions.deny. An unattended run confirms the seven entries are present, since a headless session has no prompt and an unallowed call fails. The ADR-184 reconciler registers the server in each enabled target and withdraws it with the target. The seven allow entries and themcpServersblock widen the ADR-169 operational baseline; this ADR amends ADR-169 item 1 to include them, written by the same three-way merge as the rest of the baseline. -
Where guidance lives. The mechanical layer moves into the tool descriptions and parameter schemas: the send-versus-reply choice, on
send("starts a topic; usereplywhen answering the most recent peer message") and onreply("threads to the most recent peer message; errors when the inbox is empty"), the scope parameters, the length budget, andreply's empty-inbox behavior. The judgment layer stays in the way and the skill: autonomy to reply without asking, silence as a valid reply, and the two-conduit contract. Those sentences govern whether to call any tool at all, and a description is read once the model is already choosing one. The reheat shrinks to the judgment layer and the conduit contract. The quoting rule and the "never runattend runfrom Bash" line drop from the reheat for a session with the server connected; the server writes its own presence marker, a file under attend-state keyed on the session id, on start and removes it on exit, and the reheat reads that file to select the wording. The instance record stays sensor-loop-owned (item 11). The ban on reaching into attend-owned directories stays as one sentence in the way and the skill, because Read, Edit, and Write remain available to the model. -
The interim for CLI-only sessions. The two PreToolUse denies #536 proposes ship as guard hooks under ADR-181's contract: exit 0 or 2, a closed list with a repair per entry, deactivated by default, tested in the suite. They live under
hooks/ways/, the guard-class convention ADR-181'scheck-bash-bound.pyset, ashooks/ways/attend-guard-pre.sh, andattend permissionsreports whether they are wired. Neither misuse meets ADR-162's bar for default activation; a blocked Monitor launch and a read of a cache directory are recoverable and disclose nothing. A separate attend-owned activation switch would be a second activation state beside ADR-184's targets, and this ADR declines to add one. The scripts are marked interim in their headers and are removed when the MCP frontend is the default for agent sessions. -
Envelope fields are typed parameters.
sendandreplytakemessage: stringand the optionalchannel,to,broadcast, andon_behalf_of.from_kindandfrom_idare set by the server from the connection identity and are never parameters, per #532.inboxandpeersresults carry the same fields as typed members of each row. The tool'stotakes the grammar the envelope design note fixes:*for broadcast,#<name>for a channel, or a comma-separated set of canonical ids; a project path resolves at send time to the session ids of the live peers at that path, and that resolution is how the CLI's--to PATHmaps into the same field. The field semantics, the id classes, and the wire format belong to the design note; this ADR fixes only that the fields cross the tool boundary as schema. -
The contract statement changes. "CLI is the contract" becomes: the attend tool surface is the contract, meaning the MCP tools where the server is connected and the CLI verbs everywhere; attend-owned paths remain implementation detail.
-
Ownership by role. The sensor loop is the one process per session that holds the ADR-129 duplicate lock, registers and touches the instance record, writes the seen-set, writes the last-inbound record, and supplies the pid that liveness checks. An acting frontend, whether a one-shot
attend sendor the residentattend mcp, takes none of these. It appends signal files, writes channel membership onjoinandleave, and reads everything else. Identity is derived by both processes and registered by one: each derives the session id by ADR-171 ancestry; the sensor loop registers it and allocates the persona and ordinal (ADR-129); an acting frontend derives the same id, verifies the registration exists, and adopts the registered tuple. When ancestry resolves to a Claude session and no registration exists, because the loop has not registered yet or is not running,sendandreplyrefuse with a cause namingattend run. The refusal is scoped to senders whose ancestry resolves to a Claude session: an agent session without its sensor loop cannot send, as it already cannot receive. A process with no Claude ancestry, a plain terminal, keeps the external pathidentify_senderincmd/send.rstakes today ($USER@<terminal>, source kind external, which the envelope note renders asfrom_kind: human), and attend-chat's human sender is unchanged. The MCP server is driven by a model by construction, so when its own ancestry resolves to no session it refusessendandreplyand the external path stays with the CLI. The two processes therefore never contend for a lock, never register twice, and never disagree on who the session is. Item 5 makes the resident server equivalent to the one-shot CLI: same reads at the same moment, nothing carried between calls. The library extraction in item 3 follows the same rule: verb bodies return values and frontends print, so nothing in the shared code writes to the stdout an MCP transport owns.
| State | Owner | Acting frontends |
|---|---|---|
| Duplicate lock (ADR-129) | sensor loop | never taken |
| Instance record and liveness pid | sensor loop | read |
| Seen-set (ADR-172) | sensor loop and drain | never touched |
| Last-inbound record | delivering conduit | read (reply) |
| Signal files | appended by acting frontends | append |
| Channel membership | acting frontends (join, leave) |
atomic file write, as the CLI does today |
| MCP presence marker (under attend-state, keyed on session id) | acting frontend (attend mcp) |
write on start, remove on exit |
- One state contract, then a trial. The MCP verbs and the CLI message verbs keep one state contract: the same verb against the same disk state leaves the same disk state, whichever frontend ran it. A contract test runs each verb through both frontends against one seeded state directory and diffs the result. Once implemented, agent sessions switch to the MCP frontend for daily use while the CLI message verbs stay in the binary for comparison. What follows the trial is a later decision on the evidence of use; this ADR does not remove the CLI message verbs.
Reversibility: reversible. The CLI frontend stays whole. Removing the MCP frontend deletes the mcp subcommand and the settings entries. The library extraction in item 3 stands on its own merits and would remain.
flowchart LR
Model[Model turn]
Model -->|tool call| MCP[attend mcp<br/>send reply peers inbox status join leave]
MCP --> Lib[(attend library<br/>one verb body per verb)]
CLI[attend CLI<br/>run, inbox --drain, whoami ...] --> Lib
Lib --> State[(seen-set, last_inbound,<br/>signal files)]
State -->|poll| Monitor[Monitor: attend run]
State -->|turn end| Stop[Stop hook: attend inbox --drain]
Monitor -->|notification| Model
Stop -->|injected text| Model
classDef proc fill:#2d7d9a,color:#fff,stroke:#4a5568
classDef store fill:#2d8e5e,color:#fff,stroke:#4a5568
classDef model fill:#7c3aed,color:#fff,stroke:#4a5568
class MCP,CLI,Monitor,Stop proc
class Lib,State store
class Model model
Consequences¶
Positive¶
- For a session with the server connected, three misuse classes end structurally: there is no Bash surface for
attend run, a typedmessagestring has no shell to expand it, and the envelope is schema the client validates. - The send-versus-reply guidance sits in the description of the tool being chosen, the placement the audit found agents follow most.
- An operator gains a per-tool gate on the MCP path. One permission entry separates "may read the bus" from "may write to it" there, which the single
Bash(attend:*)entry could never do. While the CLI frontend ships beside it, a hard outbound gate adds two Bash deny entries, forattend sendandattend reply. - The CLI and MCP frontends cannot drift in behavior, because each verb has one body. The MCP
replythreads to the same message the drain delivered, by construction. - The reheat's disclosure budget shrinks to the judgment sentences.
Negative¶
- Seven tool schemas sit in the context of every request for the session. Descriptions have to stay short, and the skill's messaging section has to shrink by at least as much.
- The library extraction is a refactor of
cmd/that lands before any new capability, and two frontends mean two test surfaces over one body. - A session without the server registered keeps every current failure mode. The guard hooks are opt-in, so the interim protection for those sessions is guidance unless the operator wires one settings line.
- The directory misuse loses only its Bash path. Read, Edit, and Write still reach
~/.cache/attend/; the deactivated guard is the mechanical answer, and the one-sentence ban is the shipped one. - Identity from pid ancestry assumes the client spawns the server in the session's process tree. A host that launches servers elsewhere gives the server no Claude ancestry, and
sendandreplyrefuse until it is fixed; the external sender path stays available through the CLI in a plain terminal. - An agent session whose sensor loop has not registered yet cannot send through either frontend until it does. The refusal names
attend run, and the first seconds after/attendare the window where it fires.
Neutral¶
- ADR-172's open item on MCP push is closed by the protocol revision; this ADR records the closure.
- A presence marker file under attend-state, written and removed by the server, tells the reheat, the drain footer, and
attend statusthat the MCP frontend is connected. The instance record is unchanged. - The ADR-169 operational baseline grows by the seven
mcp__attend__*allow entries and themcpServersblock, recorded as an amendment to ADR-169 item 1 in this ADR's frontmatter. -
536 becomes moot for MCP sessions and survives as the deactivated guard for the rest.¶
- The
attend chatTUI,/purge, scenes, and the signal file format are untouched. ADR-136's durability rules and ADR-173's vocabulary carry over as they stand. - The three-source lockstep becomes a two-layer rule: one description source with three renderings for the mechanical layer, and the way plus the skill for the judgment layer, with the reheat quoting the judgment layer. The lockstep comment in each file is rewritten to say so.
- The skill's
allowed-toolsgainsmcp__attend__*, and its pre-flight checks that the server is registered before choosing wording.
Alternatives Considered¶
- MCP for inbound as well, with the server delivering messages. Unavailable. Under the 2026-07-28 revision the server can respond to a client request or notify the host of a changed list or resource. Neither reaches the model's turn or wakes an idle session. The Monitor line and the Stop-hook injection are the only paths that do.
- Keep the CLI as the only frontend and add the #536 guards plus more reheat text. Rejected. The guards cover the two mechanical misuses and leave the quoting and flag classes untouched, and the disclosure tax that motivated the audit continues.
- An MCP server that shells out to the CLI. Rejected. Results would arrive as text to parse, errors as exit codes to map, and the tool schema would be a hand-kept copy of the clap surface. The library call is typed and shares the description source.
- Move every verb, including
runandinbox --drain, into the server. Rejected.runexists to feed Monitor's notification bridge, and the drain has to be invoked by the Stop hook at the turn boundary. Both are inbound machinery with no client-request shape. - One long-lived server per user over a network transport. Deferred. Per-connection identity would need the client to declare its session, where a stdio child gets it from ancestry. Item 5's fresh-invocation rule, with identity derived and verified on every call, leaves the door open without paying for it now.
- Activate the #536 guards by default. Rejected under ADR-181: neither misuse produces an irreversible or disclosing outcome.
- An attend-owned activation switch for the guards, such as a
permissionssub-verb. Rejected. ADR-184 makes the target the unit of activation; a second switch beside it would be a state the reconciler does not model. - Leave the three prose sources as they are and add the tool descriptions on top. Rejected. That makes a fourth copy of the mechanical guidance and preserves the drift the lockstep comment exists to warn about.
Closure¶
Abandoned 2026-09-30: Replaced before acceptance by ADR-501: one agent-ways MCP server (ways-mcp) with attend as a module, and Claude Code channels as an optional inbound conduit (ADR-402), which removes this record's premise that inbound cannot use MCP. Items 3, 4, 5, 7, 8, 9, 10, 11 and 12 continue under ADR-501. Item 2's tool set continues as the attend module's tools, except keepwarm, which ADR-501 moves into its keepalive module. Item 6's per-tool permission rules continue under the module-prefixed names; its registration through the settings.json merge does not, because Claude Code reads user-scope MCP servers from .claude.json only (ADR-501 item 9).