diff --git a/hive-matrix-mcp/src/accounts.rs b/hive-matrix-mcp/src/accounts.rs index 0dd3fcb9..97bd8a04 100644 --- a/hive-matrix-mcp/src/accounts.rs +++ b/hive-matrix-mcp/src/accounts.rs @@ -20,7 +20,7 @@ use std::path::PathBuf; use std::sync::Arc; use matrix_sdk::Client; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::paths; @@ -97,6 +97,28 @@ pub fn configured() -> anyhow::Result> { Ok(accounts) } +/// Live status of one matrix account, as reported by [`Registry::list`] +/// (the `list_accounts` daemon op). Only accounts that successfully +/// restored a session appear, so `live` is always `true` today; the +/// field is kept so a future "configured but down" entry can report +/// `false` without a wire-shape change. +#[derive(Debug, Serialize)] +pub struct AccountStatus { + /// Logical account name (the `account` arg on the MCP tools). + pub name: String, + /// Effective homeserver URL the restored client is talking to. + pub homeserver: String, + /// The account's own matrix user id (`@user:server`), when known. + pub user_id: Option, + /// Whether the account has a live, restored client. Always `true` + /// for registry entries today (the registry only holds restored + /// accounts); reserved for future configured-but-down reporting. + pub live: bool, + /// Whether this is the primary account (selected when a tool call + /// omits `account`). + pub is_primary: bool, +} + /// Account name → live `Client` map plus the primary-account name used /// when a request omits `account`. Built once at daemon startup from /// the accounts that successfully restored a session. @@ -127,6 +149,32 @@ impl Registry { self.by_name.is_empty() } + /// Snapshot every restored account: name, homeserver, user id, and + /// primary flag. Registry membership == a session restored, so every + /// entry is reported `live`. Sorted primary-first then by name for a + /// stable order in the dashboard. Account-agnostic — the caller does + /// not resolve a single client (see `socket::dispatch`). + #[must_use] + pub fn list(&self) -> Vec { + let mut out: Vec = self + .by_name + .iter() + .map(|(name, client)| AccountStatus { + name: name.clone(), + homeserver: client.homeserver().to_string(), + user_id: client.user_id().map(ToString::to_string), + live: true, + is_primary: *name == self.primary, + }) + .collect(); + out.sort_by(|a, b| { + b.is_primary + .cmp(&a.is_primary) + .then_with(|| a.name.cmp(&b.name)) + }); + out + } + /// Resolve a request's `account` to a client. `None` → the primary /// account. Returns a human-readable error (listing known accounts) /// when the name is unknown — surfaced to the agent as a tool error. diff --git a/hive-matrix-mcp/src/protocol.rs b/hive-matrix-mcp/src/protocol.rs index 16bbee20..7c45f48a 100644 --- a/hive-matrix-mcp/src/protocol.rs +++ b/hive-matrix-mcp/src/protocol.rs @@ -171,6 +171,16 @@ pub enum DaemonOp { #[serde(rename = "unread_summary")] UnreadSummary, + /// List the matrix accounts the daemon currently has a live, + /// restored session for. Account-agnostic (does not resolve a single + /// client — handled before client resolution in `socket::dispatch`): + /// returns each restored account's name, homeserver, user id, primary + /// flag, and a `live` flag. Backs the dashboard's per-account status + /// (BE-4) — turns BE-1's token-present list into true online/offline + /// + backfills the homeserver BE-1 leaves null. + #[serde(rename = "list_accounts")] + ListAccounts, + /// Liveness probe — fast "are you up?" round-trip that doesn't /// touch matrix-sdk. Not used by the in-tree stdio MCP bridge /// (which surfaces a daemon-down condition as a normal tool-call @@ -332,4 +342,23 @@ mod tests { assert!(parsed.account.is_none()); matches!(parsed.op, DaemonOp::UnreadCount); } + + #[test] + fn list_accounts_parses_as_account_agnostic_unit_variant() { + // Registry-wide op: no fields, and callers omit `account`. + let parsed: DaemonRequest = + serde_json::from_str(r#"{"op":{"method":"list_accounts"}}"#).unwrap(); + assert!(parsed.account.is_none()); + matches!(parsed.op, DaemonOp::ListAccounts); + // And it serialises back to the same tagged shape. + let line = serde_json::to_string(&DaemonRequest { + account: None, + op: DaemonOp::ListAccounts, + }) + .unwrap(); + assert!( + line.contains("\"method\":\"list_accounts\""), + "wire: {line}" + ); + } } diff --git a/hive-matrix-mcp/src/socket.rs b/hive-matrix-mcp/src/socket.rs index 40f55dca..165a1793 100644 --- a/hive-matrix-mcp/src/socket.rs +++ b/hive-matrix-mcp/src/socket.rs @@ -65,6 +65,12 @@ async fn dispatch(req: DaemonRequest, registry: &Registry) -> DaemonResponse { if matches!(req.op, DaemonOp::Ping) { return DaemonResponse::ok(&serde_json::json!({"ok": true})); } + // ListAccounts is registry-wide, not per-account — answer before + // resolving a single client (the `account` field is meaningless for + // it, and it must work even if the primary failed to restore). + if matches!(req.op, DaemonOp::ListAccounts) { + return DaemonResponse::ok(®istry.list()); + } let client = match registry.resolve(req.account.as_deref()) { Ok(c) => c, Err(msg) => return DaemonResponse::error(msg), @@ -78,6 +84,12 @@ async fn dispatch_op(op: DaemonOp, client: &Client) -> DaemonResponse { // a client (so a health probe works before any account restores). // Kept for an exhaustive match. DaemonOp::Ping => DaemonResponse::ok(&serde_json::json!({"ok": true})), + // Unreachable in practice: `dispatch` handles ListAccounts before + // resolving a client (it is registry-wide, not per-account). Kept + // for an exhaustive match — there is no client-scoped meaning. + DaemonOp::ListAccounts => DaemonResponse::error( + "list_accounts is registry-wide; handled before client resolution", + ), DaemonOp::SendMessage { room, body } => handlers::send_message(client, &room, &body).await, DaemonOp::SendDm { user_id, body } => handlers::send_dm(client, &user_id, &body).await, DaemonOp::SendFile {