`matrix.md` documents the exact text of every inbound matrix signal — three wake-body shapes and the invite loose-end — and none of them mention that the daemon prefixes `[acct:<name>] ` when it serves more than one account. `wake::tag_account` is live on both documented paths (`timeline.rs:75` for unread wakes, `:139` for invite todos), so an agent with an extra account receives `[acct:ccc] [matrix] @a:s in #x: hi` where the page promises a body starting `[matrix]`. The example is not hypothetical: the matrix module uses `matrix-token-ccc` on dmatrix as its worked example of an extra account. It stayed invisible because the `None` arm returns the body unchanged, so every single-account agent sees the documented format exactly. The page is right for almost every reader and wrong for precisely the readers its "Multiple accounts" section is written for. Two placements rather than one. The prefix itself goes next to the wake formats it corrects, with the worked example and the reason a leading `[matrix]` match works until a second account exists. A forward pointer goes in "Multiple accounts", because that is the section someone configuring extra accounts actually lands on, and it previously covered only the outbound `account` parameter — the half you pass, not the half you parse. Closes #4243.
167 lines
8 KiB
Markdown
167 lines
8 KiB
Markdown
# Matrix MCP tools and extra MCP servers
|
|
|
|
## Built-in matrix MCP (`mcp__matrix__*`)
|
|
|
|
When `hyperhive.matrix.enable = true` and the host-level matrix
|
|
tuwunel is configured, the harness autoinjects `hive-matrix-daemon`'s
|
|
streamable-http endpoint as a second MCP server (no stdio bridge —
|
|
see Architecture below). Tools land as `mcp__matrix__<name>`:
|
|
|
|
### Messaging
|
|
|
|
**Unread guard**: `send_message`, `send_dm`, `send_file`, and
|
|
`send_reply` are all rejected if the room has unread messages — call
|
|
`read_room` then `mark_read` on the latest event before sending to a
|
|
room you haven't read yet.
|
|
|
|
- `send_message(room, body)` — send a markdown message; `room`
|
|
accepts `!id:server` or `#alias:server`, daemon resolves either
|
|
- `send_dm(user_id, body)` — open (or reuse) the DM room with
|
|
`user_id` and post `body` to it
|
|
- `open_dm(user_id)` — resolve (find-or-create) the DM room with
|
|
`user_id` and return its room id **without sending anything**.
|
|
Use the returned id with room-based tools (`send_message`,
|
|
`send_file`, …) to deliver into a DM when `send_dm`'s body
|
|
parameter is inconvenient (for example for file attachments)
|
|
- `send_reply(room, event_id, body)` — threaded reply to a specific
|
|
event
|
|
- `send_reaction(room, event_id, key)` — react to a message with an
|
|
emoji key
|
|
- `send_file(room, path, caption?)` — upload a local file and post it
|
|
as a room attachment; MIME type is inferred from the file extension,
|
|
50 MiB cap; optional `caption` is sent as a follow-up text message
|
|
- `send_redact(room, event_id, reason?)` — redact (delete) a specific
|
|
event; works on your own events, redacting others' requires moderator
|
|
power level
|
|
|
|
### Reading
|
|
|
|
- `read_room(room, limit?)` — recent timeline events; media attachments
|
|
appear inline as `[file: name]`, `[image: name]`, `[audio: name]`,
|
|
or `[video: name]` markers — pass the event id to `download_file`
|
|
to retrieve the attachment
|
|
- `download_file(room, event_id, dest_path?)` — download a media
|
|
attachment from a message event to a local file and return its path;
|
|
pair with `read_room` which surfaces attachments as the markers above
|
|
- `list_rooms()` — enumerate joined rooms
|
|
(`{ id, canonical_alias, name, member_count }` per room)
|
|
- `list_room_members(room)` — members of a room
|
|
|
|
### Room membership
|
|
|
|
- `invite_user(room, user_id)` — invite `@user:server` into a room
|
|
you're already in; you must have a high enough power level.
|
|
The invitee sees a pending invite and resolves it via
|
|
`resolve_invite`.
|
|
- `resolve_invite(room, action)` — accept or reject a pending invite.
|
|
`action` is `"accept"` (join the room) or `"reject"` (decline and
|
|
leave). This is the path for invites; `join_room` is for joining a
|
|
public room you weren't invited to.
|
|
- `join_room(room)` — join a public room by id or alias. Also accepts
|
|
a pending invite if one exists, but prefer `resolve_invite` for
|
|
invites (it can reject too).
|
|
- `list_invites()` — rooms this agent has been invited to but not
|
|
yet joined (`{ id, canonical_alias, name }` per room).
|
|
|
|
### Receipts
|
|
|
|
- `mark_read(room, event_id)` — advance the read receipt
|
|
|
|
## Multiple accounts
|
|
|
|
`hyperhive.matrixAccounts` (declared in `agent.nix`) gives an agent
|
|
*additional* matrix identities beyond the hive-internal one — for example an
|
|
external-facing account alongside the internal one. Each entry is
|
|
keyed by account name and specifies `tokenFile` (bearer token,
|
|
provisioned out-of-band; basename must start with `matrix-token`),
|
|
`sessionDir` (per-account matrix-sdk sqlite state — crypto keys +
|
|
cache), and an optional `homeserver` (defaults to
|
|
`hyperhive.matrix.url`). The hive-internal account is always named
|
|
`main`, synthesized from `hyperhive.matrix.url` + agent state — this
|
|
option only declares extras, and `main` is a reserved key here.
|
|
Requires `hyperhive.matrix.enable = true`.
|
|
|
|
Every matrix tool above takes an optional `account` parameter (a name
|
|
from this map) to act as that identity instead of the primary one.
|
|
|
|
Declaring an extra account also changes what the agent *receives*: wake
|
|
bodies and invite todos gain an `[acct:<name>]` prefix — see
|
|
[Architecture](#architecture) below.
|
|
|
|
## Architecture
|
|
|
|
`hive-matrix-daemon` is a single long-running process (one per agent
|
|
container, systemd service in `nix/agent-modules/matrix.nix`) — no
|
|
stdio bridge, no separate bin. It owns the matrix-sdk `Client` + sync
|
|
loop per configured account **and** serves the matrix tool surface
|
|
directly over streamable-http on `hyperhive.mcp.matrixHttpPort`
|
|
(declared in `hyperhive.extraMcpServers.matrix` as
|
|
`{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and
|
|
the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects
|
|
to the stable URL every turn instead of respawning a stdio child.
|
|
Silently exits when `<state>/matrix-token` is absent (account not yet
|
|
provisioned); the `systemd.paths.hive-matrix-daemon` watcher restarts
|
|
it the moment hive-c0re provisions the token.
|
|
|
|
Incoming room events wake the agent via `AgentRequest::Wake` with
|
|
`from: "matrix"`. The wake body format depends on the unread state:
|
|
|
|
- **Single room, one message**: `[matrix] <sender> in <room>: <body> —
|
|
use read_room to view, mark_read to clear`
|
|
- **Single room, multiple messages**: `[matrix] N unread in <room> —
|
|
use read_room to view, mark_read to clear`
|
|
- **Multiple rooms**: `[matrix] unread messages:` followed by a
|
|
bulleted list (`- <room>: <sender>: <body>` or `- <room>: N unread`
|
|
per room)
|
|
|
|
**Multi-account prefix**: when the daemon serves more than one account
|
|
(`hyperhive.matrixAccounts`), it prefixes every wake body and every invite todo
|
|
below with `[acct:<name>] `, so a wake arrives as
|
|
`[acct:ccc] [matrix] <sender> in <room>: …`. A single-account agent gets the
|
|
formats exactly as written — the daemon adds nothing — which is why matching
|
|
on a leading `[matrix]` works right up until you declare a second account.
|
|
|
|
hive-matrix-daemon includes the same per-room breakdown in the `UnreadMatrix`
|
|
entry `get_loose_ends` returns, so unread rooms surface in the
|
|
loose-ends list between turns.
|
|
|
|
**Invite wakes**: the daemon sweeps `invited_rooms()` after every sync
|
|
callback (deliberately not a one-shot `m.room.member` event handler —
|
|
a one-shot signal that raced a socket-down window was dropped with no
|
|
retry, leaving the agent deaf until manually prompted) and upserts a
|
|
todo (keyed `invite:<room>`) for each pending invite on the harness's
|
|
in-agent socket, which drives a turn. The daemon does **not**
|
|
autojoin — the agent calls `list_invites` to see pending invites and
|
|
`resolve_invite` to accept or reject them.
|
|
|
|
**Pending invites as loose ends**: the daemon upserts pending invites as
|
|
keyed todos, which appear in `get_loose_ends` output as
|
|
`[matrix] invited to <room> (<room_id>) — use list_invites to see
|
|
pending invites, resolve_invite to accept or reject`. The keyed todo
|
|
is cleared when a `resolve_invite` (or `join_room`) call resolves the
|
|
invite.
|
|
|
|
See [`docs/integrations/matrix.md`](../integrations/matrix.md) for the homeserver setup,
|
|
provisioning flow, and federation config.
|
|
|
|
## Extra MCP servers (per-agent)
|
|
|
|
Each agent's NixOS config can declare additional MCP servers via
|
|
`hyperhive.extraMcpServers.<key> = { type, command, args, env, url,
|
|
allowedTools }` — `type = "stdio"` (the default, uses `command`/`args`/
|
|
`env`) or `type = "http"` (uses `url`, a long-lived streamable-http
|
|
endpoint — see `hive-bash-daemon` and `hive-matrix-daemon` above for
|
|
the `"http"` shape). The module writes the map to
|
|
`/etc/hyperhive/extra-mcp.json`; the harness reads it at boot and
|
|
merges every entry into `--mcp-config` (under `mcpServers.<key>`)
|
|
and `--allowedTools` (as `mcp__<key>__<pattern>`).
|
|
|
|
The agent's `flake.nix` forwards every flake input to `agent.nix` as
|
|
the `flakeInputs` module arg, so you pull in external MCP-server flakes
|
|
by adding them to `inputs.*` and reference them as
|
|
`flakeInputs.<name>.packages.${pkgs.system}.default` — the resolved
|
|
sha lands in the agent's own `flake.lock` and rolls up to meta's.
|
|
|
|
`allowedTools` defaults to `["*"]`, which expands to
|
|
`mcp__<key>__*` (every tool from that server autoapproved). Restrict
|
|
to specific tool names when you want finer control.
|