feat: multi-account matrix daemon (account-routed mcp surface)
This commit is contained in:
parent
73c53c7d4a
commit
8e79eb4f26
7 changed files with 613 additions and 156 deletions
|
|
@ -7,6 +7,13 @@
|
|||
//! Client + sync; the bridge is pure serde + tokio I/O. Means the MCP
|
||||
//! binary cold-starts in milliseconds even though the daemon takes
|
||||
//! seconds to bring up sync.
|
||||
//!
|
||||
//! Multi-account: every tool carries an optional `account` arg naming
|
||||
//! which matrix account to act as (a `name` from
|
||||
//! `hyperhive.matrixAccounts`); omitting it selects the agent's primary
|
||||
//! account. The bridge wraps each operation in a [`DaemonRequest`]
|
||||
//! envelope carrying that account; the daemon routes to the matching
|
||||
//! client.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rmcp::{
|
||||
|
|
@ -21,7 +28,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|||
use tokio::net::UnixStream;
|
||||
|
||||
use hive_matrix_mcp::paths;
|
||||
use hive_matrix_mcp::protocol::{DaemonRequest, DaemonResponse, InviteAction};
|
||||
use hive_matrix_mcp::protocol::{DaemonOp, 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
|
||||
|
|
@ -47,6 +54,11 @@ async fn round_trip(req: DaemonRequest) -> Result<DaemonResponse> {
|
|||
serde_json::from_str(&buf).context("parse daemon response")
|
||||
}
|
||||
|
||||
/// Wrap an op in a [`DaemonRequest`] envelope for `account` and send it.
|
||||
async fn call(account: Option<String>, op: DaemonOp) -> Result<DaemonResponse> {
|
||||
round_trip(DaemonRequest { account, op }).await
|
||||
}
|
||||
|
||||
/// Turn a `DaemonResponse` into the string claude sees as the tool
|
||||
/// result. Ok payloads are pretty-printed JSON; errors get a clear
|
||||
/// "matrix error: …" prefix so claude can pattern-match on it.
|
||||
|
|
@ -68,6 +80,10 @@ struct SendMessageArgs {
|
|||
/// Message body. Markdown is rendered to HTML by the daemon
|
||||
/// (`text_markdown`); plain text passes through unchanged.
|
||||
body: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -77,6 +93,10 @@ struct SendDmArgs {
|
|||
/// the recipient.
|
||||
user_id: String,
|
||||
body: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -90,6 +110,10 @@ struct SendFileArgs {
|
|||
/// Optional caption, sent as a follow-up text message in the room.
|
||||
#[serde(default)]
|
||||
caption: Option<String>,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -97,6 +121,10 @@ struct OpenDmArgs {
|
|||
/// Matrix user id (`@user:server`) to open a DM with. The DM room is
|
||||
/// created if one doesn't already exist.
|
||||
user_id: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -107,6 +135,10 @@ struct SendReactionArgs {
|
|||
/// Reaction key — usually an emoji (`👍`, `❤️`) but any string
|
||||
/// works per matrix spec.
|
||||
key: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -114,20 +146,37 @@ struct SendReplyArgs {
|
|||
room: String,
|
||||
event_id: String,
|
||||
body: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct MarkReadArgs {
|
||||
room: String,
|
||||
event_id: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ListRoomsArgs {}
|
||||
struct ListRoomsArgs {
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ListRoomMembersArgs {
|
||||
room: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -136,6 +185,10 @@ struct ReadRoomArgs {
|
|||
/// Maximum events to return (default 50, max 200). Newest first.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -149,15 +202,28 @@ struct DownloadFileArgs {
|
|||
/// named after the attachment; the returned `path` is where to read it.
|
||||
#[serde(default)]
|
||||
dest_path: Option<String>,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ListInvitesArgs {}
|
||||
struct ListInvitesArgs {
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct JoinRoomArgs {
|
||||
/// Matrix room id (`!abc:server`) or canonical alias (`#name:server`).
|
||||
room: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -168,6 +234,10 @@ struct ResolveInviteArgs {
|
|||
/// What to do with the invite: `"accept"` (join the room) or
|
||||
/// `"reject"` (decline and leave).
|
||||
action: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
|
@ -177,6 +247,10 @@ struct InviteUserArgs {
|
|||
room: String,
|
||||
/// Matrix user id of the invitee (`@user:server`).
|
||||
user_id: String,
|
||||
/// Matrix account to act as (a `name` from `hyperhive.matrixAccounts`).
|
||||
/// Omit to use the agent's primary account.
|
||||
#[serde(default)]
|
||||
account: Option<String>,
|
||||
}
|
||||
|
||||
struct MatrixBridge {
|
||||
|
|
@ -207,10 +281,13 @@ impl MatrixBridge {
|
|||
)]
|
||||
async fn send_message(&self, Parameters(args): Parameters<SendMessageArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::SendMessage {
|
||||
room: args.room,
|
||||
body: args.body,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::SendMessage {
|
||||
room: args.room,
|
||||
body: args.body,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -221,10 +298,13 @@ impl MatrixBridge {
|
|||
then mark_read the latest event first.")]
|
||||
async fn send_dm(&self, Parameters(args): Parameters<SendDmArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::SendDm {
|
||||
user_id: args.user_id,
|
||||
body: args.body,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::SendDm {
|
||||
user_id: args.user_id,
|
||||
body: args.body,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -238,11 +318,14 @@ impl MatrixBridge {
|
|||
)]
|
||||
async fn send_file(&self, Parameters(args): Parameters<SendFileArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::SendFile {
|
||||
room: args.room,
|
||||
path: args.path,
|
||||
caption: args.caption,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::SendFile {
|
||||
room: args.room,
|
||||
path: args.path,
|
||||
caption: args.caption,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -253,9 +336,12 @@ impl MatrixBridge {
|
|||
…) to deliver into the DM — there is no per-tool DM variant.")]
|
||||
async fn open_dm(&self, Parameters(args): Parameters<OpenDmArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::OpenDm {
|
||||
user_id: args.user_id,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::OpenDm {
|
||||
user_id: args.user_id,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -267,11 +353,14 @@ impl MatrixBridge {
|
|||
)]
|
||||
async fn send_reaction(&self, Parameters(args): Parameters<SendReactionArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::SendReaction {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
key: args.key,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::SendReaction {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
key: args.key,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -282,11 +371,14 @@ impl MatrixBridge {
|
|||
latest event first.")]
|
||||
async fn send_reply(&self, Parameters(args): Parameters<SendReplyArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::SendReply {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
body: args.body,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::SendReply {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
body: args.body,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -296,10 +388,13 @@ impl MatrixBridge {
|
|||
participants can see.")]
|
||||
async fn mark_read(&self, Parameters(args): Parameters<MarkReadArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::MarkRead {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::MarkRead {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -308,8 +403,8 @@ impl MatrixBridge {
|
|||
description = "List rooms this agent has joined. Each row has the room \
|
||||
id, canonical alias (when set), display name, and joined-member count."
|
||||
)]
|
||||
async fn list_rooms(&self, Parameters(_): Parameters<ListRoomsArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::ListRooms).await)
|
||||
async fn list_rooms(&self, Parameters(args): Parameters<ListRoomsArgs>) -> String {
|
||||
render(call(args.account, DaemonOp::ListRooms).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -317,8 +412,8 @@ impl MatrixBridge {
|
|||
Each row has the room id, canonical alias (when set), and display name. \
|
||||
Use `resolve_invite` to accept or reject an invite."
|
||||
)]
|
||||
async fn list_invites(&self, Parameters(_): Parameters<ListInvitesArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::ListInvites).await)
|
||||
async fn list_invites(&self, Parameters(args): Parameters<ListInvitesArgs>) -> String {
|
||||
render(call(args.account, DaemonOp::ListInvites).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -329,7 +424,7 @@ impl MatrixBridge {
|
|||
`list_rooms`."
|
||||
)]
|
||||
async fn join_room(&self, Parameters(args): Parameters<JoinRoomArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::JoinRoom { room: args.room }).await)
|
||||
render(call(args.account, DaemonOp::JoinRoom { room: args.room }).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -349,10 +444,13 @@ impl MatrixBridge {
|
|||
}
|
||||
};
|
||||
render(
|
||||
round_trip(DaemonRequest::ResolveInvite {
|
||||
room: args.room,
|
||||
action,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::ResolveInvite {
|
||||
room: args.room,
|
||||
action,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -365,10 +463,13 @@ impl MatrixBridge {
|
|||
)]
|
||||
async fn invite_user(&self, Parameters(args): Parameters<InviteUserArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::InviteUser {
|
||||
room: args.room,
|
||||
user_id: args.user_id,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::InviteUser {
|
||||
room: args.room,
|
||||
user_id: args.user_id,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -378,7 +479,7 @@ impl MatrixBridge {
|
|||
Each row carries the user id and resolved display name."
|
||||
)]
|
||||
async fn list_room_members(&self, Parameters(args): Parameters<ListRoomMembersArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::ListRoomMembers { room: args.room }).await)
|
||||
render(call(args.account, DaemonOp::ListRoomMembers { room: args.room }).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Read the most recent N events from a matrix room \
|
||||
|
|
@ -386,10 +487,13 @@ impl MatrixBridge {
|
|||
type, and best-effort plain-text body.")]
|
||||
async fn read_room(&self, Parameters(args): Parameters<ReadRoomArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::ReadRoom {
|
||||
room: args.room,
|
||||
limit: args.limit,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::ReadRoom {
|
||||
room: args.room,
|
||||
limit: args.limit,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -403,11 +507,14 @@ impl MatrixBridge {
|
|||
)]
|
||||
async fn download_file(&self, Parameters(args): Parameters<DownloadFileArgs>) -> String {
|
||||
render(
|
||||
round_trip(DaemonRequest::DownloadFile {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
dest_path: args.dest_path,
|
||||
})
|
||||
call(
|
||||
args.account,
|
||||
DaemonOp::DownloadFile {
|
||||
room: args.room,
|
||||
event_id: args.event_id,
|
||||
dest_path: args.dest_path,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
|
@ -421,7 +528,9 @@ impl MatrixBridge {
|
|||
timeline with `read_room`. See pending invites with `list_invites`; \
|
||||
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.")]
|
||||
or aliases (#name:server); user references use @user:server. Every \
|
||||
tool takes an optional `account` (a name from the agent's matrix \
|
||||
accounts) — omit it to act as the primary account.")]
|
||||
impl ServerHandler for MatrixBridge {}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
|
|||
Loading…
Reference in a new issue