From 86f0de751ff9066e82dab432547a13bb9d484862 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 5 Jun 2026 21:55:04 +0200 Subject: [PATCH] feat(matrix mcp): add resolve_invite tool to accept or reject pending invites --- docs/tools/matrix.md | 21 +++++++++----- hive-matrix-mcp/src/bin/mcp.rs | 51 ++++++++++++++++++++++++++++----- hive-matrix-mcp/src/handlers.rs | 39 +++++++++++++++++++++++-- hive-matrix-mcp/src/protocol.rs | 19 ++++++++++++ hive-matrix-mcp/src/socket.rs | 3 ++ hive-matrix-mcp/src/timeline.rs | 7 +++-- 6 files changed, 121 insertions(+), 19 deletions(-) diff --git a/docs/tools/matrix.md b/docs/tools/matrix.md index a6ea36ac..b3ee31b3 100644 --- a/docs/tools/matrix.md +++ b/docs/tools/matrix.md @@ -27,9 +27,15 @@ a second stdio MCP server. Tools land as `mcp__matrix__`: - `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 accepts via `join_room`. -- `join_room(room)` — join a room by id or alias, or accept a - pending invite. + 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). @@ -64,13 +70,14 @@ loose-ends list between turns. `m.room.member` invite event, it writes the invite to `mcp-loose-ends/matrix.json` and fires a hyperhive wake. The daemon does **not** auto-join — the agent calls `list_invites` to see pending -invites and `join_room` to accept (or ignore). +invites and `resolve_invite` to accept or reject them. **Pending invites as loose ends**: pending invites are written to `mcp-loose-ends/matrix.json` and appear in `get_loose_ends` output as -`[matrix] pending invite: — use list_invites to see, join_room -to accept`. The file is updated atomically after each invite event -and after each `join_room` call clears the entry. +`[matrix] pending invite: — use list_invites to see, +resolve_invite to accept or reject`. The file is updated atomically +after each invite event and after each `resolve_invite` (or +`join_room`) call clears the entry. See [`docs/matrix.md`](../matrix.md) for the homeserver setup, provisioning flow, and federation config. diff --git a/hive-matrix-mcp/src/bin/mcp.rs b/hive-matrix-mcp/src/bin/mcp.rs index aefc5827..cb0cf030 100644 --- a/hive-matrix-mcp/src/bin/mcp.rs +++ b/hive-matrix-mcp/src/bin/mcp.rs @@ -21,7 +21,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use hive_matrix_mcp::paths; -use hive_matrix_mcp::protocol::{DaemonRequest, DaemonResponse}; +use hive_matrix_mcp::protocol::{DaemonRequest, DaemonResponse, InviteAction}; /// Send `req` to the daemon and read back the response. Each call is a /// fresh unix-socket connection — short-lived (the daemon dispatch is @@ -127,6 +127,16 @@ struct JoinRoomArgs { room: String, } +#[derive(Debug, Deserialize, JsonSchema)] +struct ResolveInviteArgs { + /// Matrix room id (`!abc:server`) or alias (`#name:server`) you have + /// a pending invite to. + room: String, + /// What to do with the invite: `"accept"` (join the room) or + /// `"reject"` (decline and leave). + action: String, +} + #[derive(Debug, Deserialize, JsonSchema)] struct InviteUserArgs { /// Matrix room id (`!abc:server`) or canonical alias (`#name:server`) @@ -231,7 +241,7 @@ impl MatrixBridge { #[tool( description = "List rooms this agent has been invited to but not yet joined. \ Each row has the room id, canonical alias (when set), and display name. \ - Use `join_room` to accept an invite." + Use `resolve_invite` to accept or reject an invite." )] async fn list_invites(&self, Parameters(_): Parameters) -> String { render(round_trip(DaemonRequest::ListInvites).await) @@ -239,13 +249,40 @@ impl MatrixBridge { #[tool( description = "Join a matrix room by id (!abc:server) or alias (#name:server). \ - Accepts a pending invite if one exists; also joins public rooms. \ - After joining the room will appear in `list_rooms`." + Also accepts a pending invite if one exists, but for invites prefer \ + `resolve_invite` (which can also reject). Use `join_room` to join a \ + public room you weren't invited to. After joining the room appears in \ + `list_rooms`." )] async fn join_room(&self, Parameters(args): Parameters) -> String { render(round_trip(DaemonRequest::JoinRoom { room: args.room }).await) } + #[tool( + description = "Accept or reject a pending matrix invite. `room` is a room id \ + (!abc:server) or alias (#name:server) you were invited to; `action` is \ + \"accept\" (join the room) or \"reject\" (decline and leave). See pending \ + invites with `list_invites`." + )] + async fn resolve_invite(&self, Parameters(args): Parameters) -> String { + let action = match args.action.trim().to_ascii_lowercase().as_str() { + "accept" => InviteAction::Accept, + "reject" => InviteAction::Reject, + other => { + return format!( + "matrix error: invalid action {other:?} — use \"accept\" or \"reject\"" + ); + } + }; + render( + round_trip(DaemonRequest::ResolveInvite { + room: args.room, + action, + }) + .await, + ) + } + #[tool( description = "Invite a user to a matrix room you're already in. `room` is a \ room id (!abc:server) or alias (#name:server); `user_id` is the invitee \ @@ -290,9 +327,9 @@ impl MatrixBridge { to thread a reply, `mark_read` to acknowledge an event. Discover \ rooms with `list_rooms`, members with `list_room_members`, recent \ timeline with `read_room`. See pending invites with `list_invites`; \ - accept an invite or join a public room with `join_room`. Room \ - references accept ids (!abc:server) or aliases (#name:server); \ - user references use @user:server.")] + accept or reject an invite with `resolve_invite`; join a public \ + room with `join_room`. Room references accept ids (!abc:server) \ + or aliases (#name:server); user references use @user:server.")] impl ServerHandler for MatrixBridge {} #[tokio::main] diff --git a/hive-matrix-mcp/src/handlers.rs b/hive-matrix-mcp/src/handlers.rs index ad3549dd..1be0d4a2 100644 --- a/hive-matrix-mcp/src/handlers.rs +++ b/hive-matrix-mcp/src/handlers.rs @@ -25,7 +25,7 @@ use matrix_sdk::{ }; use serde::Serialize; -use crate::protocol::DaemonResponse; +use crate::protocol::{DaemonResponse, InviteAction}; /// JSON-shape for `list_invites`: one row per pending room invite. #[derive(Debug, Serialize)] @@ -309,7 +309,7 @@ pub async fn refresh_invite_loose_ends(client: &Client) { .canonical_alias() .map_or_else(|| room.room_id().to_string(), |a| a.to_string()); format!( - "[matrix] pending invite: {label} — use list_invites to see, join_room to accept" + "[matrix] pending invite: {label} — use list_invites to see, resolve_invite to accept or reject" ) }) .collect(); @@ -350,6 +350,41 @@ pub async fn join_room(client: &Client, room_ref: &str) -> DaemonResponse { } } +/// Accept or reject a pending invite to `room_ref` (id or alias). +/// +/// `Accept` is exactly `join_room` (joining a room you were invited to +/// accepts the invite). `Reject` resolves the invited room and leaves +/// it, declining the invite. Either way the resolved invite drops out +/// of `get_loose_ends` immediately. +pub async fn resolve_invite( + client: &Client, + room_ref: &str, + action: InviteAction, +) -> DaemonResponse { + match action { + InviteAction::Accept => join_room(client, room_ref).await, + InviteAction::Reject => { + // `resolve_room` -> `get_room` returns the room in any + // membership state the store knows, including `Invited`, so + // this works for a pending invite that was never joined. + let room = match resolve_room(client, room_ref).await { + Ok(r) => r, + Err(e) => return e, + }; + match room.leave().await { + Ok(()) => { + refresh_invite_loose_ends(client).await; + DaemonResponse::ok(&serde_json::json!({ + "rejected": true, + "room_id": room.room_id().to_string(), + })) + } + Err(e) => DaemonResponse::error(format!("reject invite {room_ref}: {e}")), + } + } + } +} + /// Invite `user_id` to `room_ref`. The calling agent must be a member of /// the room with a power level high enough to invite; otherwise the /// homeserver rejects it and the error is surfaced verbatim. diff --git a/hive-matrix-mcp/src/protocol.rs b/hive-matrix-mcp/src/protocol.rs index a855c7de..bd71884f 100644 --- a/hive-matrix-mcp/src/protocol.rs +++ b/hive-matrix-mcp/src/protocol.rs @@ -81,6 +81,13 @@ pub enum DaemonRequest { #[serde(rename = "join_room")] JoinRoom { room: String }, + /// Resolve a pending invite to `room` (id or alias) by either + /// accepting it (join) or rejecting it (decline + leave). For rooms + /// you were *invited* to; `join_room` is the path for joining a + /// public room you weren't invited to. + #[serde(rename = "resolve_invite")] + ResolveInvite { room: String, action: InviteAction }, + /// Invite `user_id` (`@user:server`) to `room` (id or alias). The /// calling agent must already be a member with a high enough power /// level to invite. Idempotent-ish: inviting an already-joined or @@ -110,6 +117,18 @@ pub enum DaemonRequest { Ping, } +/// Whether to accept or reject a pending invite in +/// [`DaemonRequest::ResolveInvite`]. Serialises as `"accept"` / +/// `"reject"` on the wire. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InviteAction { + /// Accept the invite — join the room (same effect as `join_room`). + Accept, + /// Reject the invite — decline it and leave the room. + Reject, +} + /// One entry in the [`DaemonRequest::UnreadSummary`] response payload. #[derive(Debug, Serialize, Deserialize)] pub struct RoomUnread { diff --git a/hive-matrix-mcp/src/socket.rs b/hive-matrix-mcp/src/socket.rs index 168ceba9..0f09b876 100644 --- a/hive-matrix-mcp/src/socket.rs +++ b/hive-matrix-mcp/src/socket.rs @@ -81,6 +81,9 @@ async fn dispatch(req: DaemonRequest, client: &Client) -> DaemonResponse { DaemonRequest::ListRooms => handlers::list_rooms(client).await, DaemonRequest::ListInvites => handlers::list_invites(client), DaemonRequest::JoinRoom { room } => handlers::join_room(client, &room).await, + DaemonRequest::ResolveInvite { room, action } => { + handlers::resolve_invite(client, &room, action).await + } DaemonRequest::InviteUser { room, user_id } => { handlers::invite_user(client, &room, &user_id).await } diff --git a/hive-matrix-mcp/src/timeline.rs b/hive-matrix-mcp/src/timeline.rs index aa8ed353..d1b5eccf 100644 --- a/hive-matrix-mcp/src/timeline.rs +++ b/hive-matrix-mcp/src/timeline.rs @@ -77,12 +77,13 @@ pub fn install_message_handler(client: &Client, hyperhive_socket: PathBuf) { } /// Install a handler that wakes the agent when a room invite arrives. -/// The agent decides whether to accept by calling `join_room`. +/// The agent decides whether to accept or reject by calling +/// `resolve_invite`. /// /// When the daemon receives an `m.room.member` state event with /// `membership: invite` for the agent's own user ID during sync, it /// fires a hyperhive wake so the agent can inspect the invite via -/// `list_invites` and act on it with `join_room`. +/// `list_invites` and act on it with `resolve_invite`. /// /// This closes the gap where invites accumulated in `invited_rooms()` /// forever without the agent being notified — the daemon's message @@ -113,7 +114,7 @@ pub fn install_invite_handler(client: &Client, hyperhive_socket: PathBuf) { handlers::refresh_invite_loose_ends(&client).await; let body = format!( "[matrix] invited to {label} ({room_id}) — \ - use list_invites to see pending invites, join_room to accept" + use list_invites to see pending invites, resolve_invite to accept or reject" ); if let Err(e) = wake::send_wake(&socket, &body).await { tracing::warn!(