feat(matrix mcp): add resolve_invite tool to accept or reject pending invites
This commit is contained in:
parent
c59a0de01e
commit
86f0de751f
6 changed files with 121 additions and 19 deletions
|
|
@ -27,9 +27,15 @@ a second stdio MCP server. Tools land as `mcp__matrix__<name>`:
|
||||||
|
|
||||||
- `invite_user(room, user_id)` — invite `@user:server` into a room
|
- `invite_user(room, user_id)` — invite `@user:server` into a room
|
||||||
you're already in; you must have a high enough power level.
|
you're already in; you must have a high enough power level.
|
||||||
The invitee sees a pending invite and accepts via `join_room`.
|
The invitee sees a pending invite and resolves it via
|
||||||
- `join_room(room)` — join a room by id or alias, or accept a
|
`resolve_invite`.
|
||||||
pending 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
|
- `list_invites()` — rooms this agent has been invited to but not
|
||||||
yet joined (`{ id, canonical_alias, name }` per room).
|
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
|
`m.room.member` invite event, it writes the invite to
|
||||||
`mcp-loose-ends/matrix.json` and fires a hyperhive wake. The daemon
|
`mcp-loose-ends/matrix.json` and fires a hyperhive wake. The daemon
|
||||||
does **not** auto-join — the agent calls `list_invites` to see pending
|
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
|
**Pending invites as loose ends**: pending invites are written to
|
||||||
`mcp-loose-ends/matrix.json` and appear in `get_loose_ends` output as
|
`mcp-loose-ends/matrix.json` and appear in `get_loose_ends` output as
|
||||||
`[matrix] pending invite: <room> — use list_invites to see, join_room
|
`[matrix] pending invite: <room> — use list_invites to see,
|
||||||
to accept`. The file is updated atomically after each invite event
|
resolve_invite to accept or reject`. The file is updated atomically
|
||||||
and after each `join_room` call clears the entry.
|
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,
|
See [`docs/matrix.md`](../matrix.md) for the homeserver setup,
|
||||||
provisioning flow, and federation config.
|
provisioning flow, and federation config.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
use hive_matrix_mcp::paths;
|
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
|
/// Send `req` to the daemon and read back the response. Each call is a
|
||||||
/// fresh unix-socket connection — short-lived (the daemon dispatch is
|
/// fresh unix-socket connection — short-lived (the daemon dispatch is
|
||||||
|
|
@ -127,6 +127,16 @@ struct JoinRoomArgs {
|
||||||
room: String,
|
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)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct InviteUserArgs {
|
struct InviteUserArgs {
|
||||||
/// Matrix room id (`!abc:server`) or canonical alias (`#name:server`)
|
/// Matrix room id (`!abc:server`) or canonical alias (`#name:server`)
|
||||||
|
|
@ -231,7 +241,7 @@ impl MatrixBridge {
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "List rooms this agent has been invited to but not yet joined. \
|
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. \
|
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<ListInvitesArgs>) -> String {
|
async fn list_invites(&self, Parameters(_): Parameters<ListInvitesArgs>) -> String {
|
||||||
render(round_trip(DaemonRequest::ListInvites).await)
|
render(round_trip(DaemonRequest::ListInvites).await)
|
||||||
|
|
@ -239,13 +249,40 @@ impl MatrixBridge {
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
description = "Join a matrix room by id (!abc:server) or alias (#name:server). \
|
description = "Join a matrix room by id (!abc:server) or alias (#name:server). \
|
||||||
Accepts a pending invite if one exists; also joins public rooms. \
|
Also accepts a pending invite if one exists, but for invites prefer \
|
||||||
After joining the room will appear in `list_rooms`."
|
`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<JoinRoomArgs>) -> String {
|
async fn join_room(&self, Parameters(args): Parameters<JoinRoomArgs>) -> String {
|
||||||
render(round_trip(DaemonRequest::JoinRoom { room: args.room }).await)
|
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<ResolveInviteArgs>) -> 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(
|
#[tool(
|
||||||
description = "Invite a user to a matrix room you're already in. `room` is a \
|
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 \
|
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 \
|
to thread a reply, `mark_read` to acknowledge an event. Discover \
|
||||||
rooms with `list_rooms`, members with `list_room_members`, recent \
|
rooms with `list_rooms`, members with `list_room_members`, recent \
|
||||||
timeline with `read_room`. See pending invites with `list_invites`; \
|
timeline with `read_room`. See pending invites with `list_invites`; \
|
||||||
accept an invite or join a public room with `join_room`. Room \
|
accept or reject an invite with `resolve_invite`; join a public \
|
||||||
references accept ids (!abc:server) or aliases (#name:server); \
|
room with `join_room`. Room references accept ids (!abc:server) \
|
||||||
user references use @user:server.")]
|
or aliases (#name:server); user references use @user:server.")]
|
||||||
impl ServerHandler for MatrixBridge {}
|
impl ServerHandler for MatrixBridge {}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ use matrix_sdk::{
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::protocol::DaemonResponse;
|
use crate::protocol::{DaemonResponse, InviteAction};
|
||||||
|
|
||||||
/// JSON-shape for `list_invites`: one row per pending room invite.
|
/// JSON-shape for `list_invites`: one row per pending room invite.
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -309,7 +309,7 @@ pub async fn refresh_invite_loose_ends(client: &Client) {
|
||||||
.canonical_alias()
|
.canonical_alias()
|
||||||
.map_or_else(|| room.room_id().to_string(), |a| a.to_string());
|
.map_or_else(|| room.room_id().to_string(), |a| a.to_string());
|
||||||
format!(
|
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();
|
.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
|
/// 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
|
/// the room with a power level high enough to invite; otherwise the
|
||||||
/// homeserver rejects it and the error is surfaced verbatim.
|
/// homeserver rejects it and the error is surfaced verbatim.
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,13 @@ pub enum DaemonRequest {
|
||||||
#[serde(rename = "join_room")]
|
#[serde(rename = "join_room")]
|
||||||
JoinRoom { room: String },
|
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
|
/// Invite `user_id` (`@user:server`) to `room` (id or alias). The
|
||||||
/// calling agent must already be a member with a high enough power
|
/// calling agent must already be a member with a high enough power
|
||||||
/// level to invite. Idempotent-ish: inviting an already-joined or
|
/// level to invite. Idempotent-ish: inviting an already-joined or
|
||||||
|
|
@ -110,6 +117,18 @@ pub enum DaemonRequest {
|
||||||
Ping,
|
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.
|
/// One entry in the [`DaemonRequest::UnreadSummary`] response payload.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct RoomUnread {
|
pub struct RoomUnread {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,9 @@ async fn dispatch(req: DaemonRequest, client: &Client) -> DaemonResponse {
|
||||||
DaemonRequest::ListRooms => handlers::list_rooms(client).await,
|
DaemonRequest::ListRooms => handlers::list_rooms(client).await,
|
||||||
DaemonRequest::ListInvites => handlers::list_invites(client),
|
DaemonRequest::ListInvites => handlers::list_invites(client),
|
||||||
DaemonRequest::JoinRoom { room } => handlers::join_room(client, &room).await,
|
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 } => {
|
DaemonRequest::InviteUser { room, user_id } => {
|
||||||
handlers::invite_user(client, &room, &user_id).await
|
handlers::invite_user(client, &room, &user_id).await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// 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
|
/// When the daemon receives an `m.room.member` state event with
|
||||||
/// `membership: invite` for the agent's own user ID during sync, it
|
/// `membership: invite` for the agent's own user ID during sync, it
|
||||||
/// fires a hyperhive wake so the agent can inspect the invite via
|
/// 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()`
|
/// This closes the gap where invites accumulated in `invited_rooms()`
|
||||||
/// forever without the agent being notified — the daemon's message
|
/// 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;
|
handlers::refresh_invite_loose_ends(&client).await;
|
||||||
let body = format!(
|
let body = format!(
|
||||||
"[matrix] invited to {label} ({room_id}) — \
|
"[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 {
|
if let Err(e) = wake::send_wake(&socket, &body).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue