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
|
|
@ -9,12 +9,34 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request from the stdio MCP bridge to the daemon. The MCP bridge
|
||||
/// Request envelope from the stdio MCP bridge to the daemon: which
|
||||
/// matrix `account` to act as, plus the operation itself. The daemon
|
||||
/// holds an account→Client registry (one client per declared matrix
|
||||
/// account) and routes `op` to the resolved client.
|
||||
///
|
||||
/// `account` is nested rather than flattened onto [`DaemonOp`] so we
|
||||
/// dodge the serde "internally-tagged enum + `#[serde(flatten)]`"
|
||||
/// edge cases; the bridge and daemon ship together so the wire shape
|
||||
/// is private. Wire:
|
||||
/// `{"account":"ccc","op":{"method":"send_message","room":…,"body":…}}`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DaemonRequest {
|
||||
/// Logical account name to act as (matches a `name` in
|
||||
/// `hyperhive.matrixAccounts`). `None` selects the primary account
|
||||
/// (the first declared one / the single legacy account), so
|
||||
/// single-account callers omit it entirely.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account: Option<String>,
|
||||
/// The matrix operation to perform on the resolved account.
|
||||
pub op: DaemonOp,
|
||||
}
|
||||
|
||||
/// The matrix operation a [`DaemonRequest`] carries. The MCP bridge
|
||||
/// owns the on-wire shape claude sees; this enum is the internal
|
||||
/// shape the daemon dispatches over.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "method")]
|
||||
pub enum DaemonRequest {
|
||||
pub enum DaemonOp {
|
||||
/// Post a plain-text or markdown message to a room. `room` accepts
|
||||
/// either a matrix room id (`!abc:server`) or a canonical alias
|
||||
/// (`#name:server`); the daemon resolves aliases server-side.
|
||||
|
|
@ -204,3 +226,53 @@ impl DaemonResponse {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn envelope_round_trips_with_account() {
|
||||
let req = DaemonRequest {
|
||||
account: Some("ccc".to_owned()),
|
||||
op: DaemonOp::SendMessage {
|
||||
room: "!r:s".to_owned(),
|
||||
body: "hi".to_owned(),
|
||||
},
|
||||
};
|
||||
let line = serde_json::to_string(&req).unwrap();
|
||||
// account + nested tagged op present on the wire.
|
||||
assert!(line.contains("\"account\":\"ccc\""), "wire: {line}");
|
||||
assert!(line.contains("\"method\":\"send_message\""), "wire: {line}");
|
||||
let back: DaemonRequest = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(back.account.as_deref(), Some("ccc"));
|
||||
matches!(back.op, DaemonOp::SendMessage { .. });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_defaults_account_to_none_and_omits_it() {
|
||||
// Single-account callers send no `account`; it must default to
|
||||
// None and not appear on the wire (skip_serializing_if).
|
||||
let req = DaemonRequest {
|
||||
account: None,
|
||||
op: DaemonOp::ListRooms,
|
||||
};
|
||||
let line = serde_json::to_string(&req).unwrap();
|
||||
assert!(
|
||||
!line.contains("account"),
|
||||
"wire should omit account: {line}"
|
||||
);
|
||||
let back: DaemonRequest = serde_json::from_str(&line).unwrap();
|
||||
assert!(back.account.is_none());
|
||||
matches!(back.op, DaemonOp::ListRooms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_variant_op_parses_inside_envelope() {
|
||||
// A bare op with no fields still parses when wrapped.
|
||||
let parsed: DaemonRequest =
|
||||
serde_json::from_str(r#"{"op":{"method":"unread_count"}}"#).unwrap();
|
||||
assert!(parsed.account.is_none());
|
||||
matches!(parsed.op, DaemonOp::UnreadCount);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue