feat(#2659): serve hive-matrix-mcp over persistent streamable-http, drop stdio bridge
This commit is contained in:
parent
ae8d1aaac4
commit
a66b7ab298
21 changed files with 411 additions and 856 deletions
|
|
@ -1,208 +1,18 @@
|
|||
//! Wire types for the daemon ↔ stdio-MCP-bridge unix socket protocol.
|
||||
//! Shared response/DTO shapes for the matrix tool surface.
|
||||
//!
|
||||
//! Same shape as `damocles-daemon`'s `DaemonRequest`/`DaemonResponse`
|
||||
//! (which this is forked from in spirit). Each request is a single
|
||||
//! JSON line; each response is a single JSON line back. The stdio MCP
|
||||
//! bridge holds a fresh connection per tool call — claude's tool
|
||||
//! lifecycle is shorter than a persistent matrix-sdk Client wants to
|
||||
//! live, so the daemon stays alive and the MCP reconnects per call.
|
||||
//! `hive-matrix-daemon` serves its MCP tools directly over
|
||||
//! streamable-http (see [`crate::mcp`]) — there is no separate bridge
|
||||
//! process and no wire protocol between two binaries any more, so this
|
||||
//! module carries only the handler-facing result type
|
||||
//! ([`DaemonResponse`]) and small DTOs ([`InviteAction`],
|
||||
//! [`RoomUnread`]) shared between [`crate::handlers`] and its callers
|
||||
//! (the MCP tool router, the wake-signal formatter).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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 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.
|
||||
#[serde(rename = "send_message")]
|
||||
SendMessage { room: String, body: String },
|
||||
|
||||
/// Open (or reuse) a DM with `user_id` and post `body`. Creates
|
||||
/// the DM room if one doesn't already exist between this agent
|
||||
/// and the user.
|
||||
#[serde(rename = "send_dm")]
|
||||
SendDm { user_id: String, body: String },
|
||||
|
||||
/// Upload a local file and post it as an attachment to `room`
|
||||
/// (id or alias). `caption`, when set, is sent as a follow-up
|
||||
/// text message in the same room.
|
||||
#[serde(rename = "send_file")]
|
||||
SendFile {
|
||||
room: String,
|
||||
path: String,
|
||||
caption: Option<String>,
|
||||
},
|
||||
|
||||
/// Resolve (find-or-create) the DM room with `user_id` and return
|
||||
/// its room id, without sending anything. Lets a caller obtain the
|
||||
/// DM room id and then use the room-based tools (`send_file`,
|
||||
/// `send_message`, …) against it — so there is no per-tool `_dm`
|
||||
/// variant.
|
||||
#[serde(rename = "open_dm")]
|
||||
OpenDm { user_id: String },
|
||||
|
||||
/// React to a specific event with an emoji `key`. Matrix-spec
|
||||
/// `m.reaction` annotation.
|
||||
#[serde(rename = "send_reaction")]
|
||||
SendReaction {
|
||||
room: String,
|
||||
event_id: String,
|
||||
key: String,
|
||||
},
|
||||
|
||||
/// Reply to `event_id` in `room` with `body` as a threaded reply.
|
||||
/// Sets the `m.in_reply_to` relation so matrix clients render the
|
||||
/// thread.
|
||||
#[serde(rename = "send_reply")]
|
||||
SendReply {
|
||||
room: String,
|
||||
event_id: String,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// Mark `event_id` (in `room`) as read for this agent. Sends a
|
||||
/// read receipt; bumps the room's "unread" indicator down on
|
||||
/// matrix clients (and for other agents).
|
||||
#[serde(rename = "mark_read")]
|
||||
MarkRead { room: String, event_id: String },
|
||||
|
||||
/// Redact `event_id` in `room` — ask the homeserver to strip the
|
||||
/// event's content (matrix-spec `m.room.redaction`), optionally with
|
||||
/// a human-readable `reason`. The agent must have a high enough power
|
||||
/// level (its own events, or moderator rights for others'); the
|
||||
/// server rejects otherwise.
|
||||
#[serde(rename = "send_redact")]
|
||||
SendRedact {
|
||||
room: String,
|
||||
event_id: String,
|
||||
reason: Option<String>,
|
||||
},
|
||||
|
||||
/// List rooms the agent has joined. Returns each room's id +
|
||||
/// canonical alias (when present) + name + member count.
|
||||
#[serde(rename = "list_rooms")]
|
||||
ListRooms,
|
||||
|
||||
/// List the members of a room. Each entry carries the matrix
|
||||
/// user id + the resolved display name (when set).
|
||||
#[serde(rename = "list_room_members")]
|
||||
ListRoomMembers { room: String },
|
||||
|
||||
/// Read events from a room's timeline. Caller gets each event's id,
|
||||
/// sender, `server_ts`, type, and body (best-effort plain-text extraction
|
||||
/// from `m.text` / `m.notice` etc.). With neither cursor, returns the last
|
||||
/// `limit` events (newest-first). `from` / `until` anchor at an event id
|
||||
/// (mutually exclusive): `until` reads the anchor + `limit-1` events before
|
||||
/// it (into the past); `from` reads the anchor + `limit-1` events after it.
|
||||
#[serde(rename = "read_room")]
|
||||
ReadRoom {
|
||||
room: String,
|
||||
limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
from: Option<String>,
|
||||
#[serde(default)]
|
||||
until: Option<String>,
|
||||
},
|
||||
|
||||
/// Download the media attachment carried by `event_id` in `room`
|
||||
/// and write it to a local file (`dest_path`, or a temp file named
|
||||
/// after the attachment when omitted), returning the path. The
|
||||
/// read-side counterpart of `send_file`.
|
||||
#[serde(rename = "download_file")]
|
||||
DownloadFile {
|
||||
room: String,
|
||||
event_id: String,
|
||||
dest_path: Option<String>,
|
||||
},
|
||||
|
||||
/// List rooms this agent has been invited to but not yet joined.
|
||||
/// Returns each room's id, canonical alias (when present), and
|
||||
/// display name.
|
||||
#[serde(rename = "list_invites")]
|
||||
ListInvites,
|
||||
|
||||
/// Join a room by id (`!abc:server`) or alias (`#name:server`).
|
||||
/// Accepts a pending invite if one exists; also joins public rooms
|
||||
/// the agent hasn't been explicitly invited to. After joining the
|
||||
/// room will appear in `list_rooms`.
|
||||
#[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
|
||||
/// already-invited user surfaces the matrix error from the server.
|
||||
#[serde(rename = "invite_user")]
|
||||
InviteUser { room: String, user_id: String },
|
||||
|
||||
/// Return the count of rooms with unread notifications. Used by
|
||||
/// the harness `get_loose_ends` to surface unread matrix activity
|
||||
/// without exposing message content.
|
||||
#[serde(rename = "unread_count")]
|
||||
UnreadCount,
|
||||
|
||||
/// Return per-room unread summaries. For rooms with exactly one
|
||||
/// unread notification, attempts to include the sender + truncated
|
||||
/// body; rooms with multiple unreads carry only the count. Used by
|
||||
/// `get_loose_ends` and the wake-signal formatter.
|
||||
#[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
|
||||
/// connect error); reserved for external clients that want an
|
||||
/// explicit health check without doing real work.
|
||||
#[serde(rename = "ping")]
|
||||
Ping,
|
||||
}
|
||||
|
||||
/// Whether to accept or reject a pending invite in
|
||||
/// [`DaemonRequest::ResolveInvite`]. Serialises as `"accept"` /
|
||||
/// `"reject"` on the wire.
|
||||
/// Whether to accept or reject a pending invite (`resolve_invite`
|
||||
/// tool). Serialises as `"accept"` / `"reject"` on the wire (kept
|
||||
/// `Serialize`/`Deserialize` for the JSON DTOs handlers build).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InviteAction {
|
||||
|
|
@ -212,7 +22,7 @@ pub enum InviteAction {
|
|||
Reject,
|
||||
}
|
||||
|
||||
/// One entry in the [`DaemonRequest::UnreadSummary`] response payload.
|
||||
/// One entry in the `unread_summary` response payload.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RoomUnread {
|
||||
/// Canonical alias (`#name:server`) or room id (`!id:server`).
|
||||
|
|
@ -229,9 +39,13 @@ pub struct RoomUnread {
|
|||
pub last_sender: Option<String>,
|
||||
}
|
||||
|
||||
/// Response shape: `ok` carries the payload (any JSON; the MCP bridge
|
||||
/// passes it back to claude as the tool result), `error` carries a
|
||||
/// human-readable error string.
|
||||
/// Result shape every [`crate::handlers`] function returns: `Ok`
|
||||
/// carries the payload (any JSON; the MCP tool router renders it as
|
||||
/// the tool result string), `Error` carries a human-readable error
|
||||
/// string. Kept as a distinct type (rather than each handler
|
||||
/// returning a bare `String`) so the tool router can uniformly render
|
||||
/// success vs error without every handler duplicating that
|
||||
/// formatting.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum DaemonResponse {
|
||||
|
|
@ -258,117 +72,3 @@ 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 send_redact_round_trips_with_reason() {
|
||||
let req = DaemonRequest {
|
||||
account: None,
|
||||
op: DaemonOp::SendRedact {
|
||||
room: "!r:s".to_owned(),
|
||||
event_id: "$e".to_owned(),
|
||||
reason: Some("spam".to_owned()),
|
||||
},
|
||||
};
|
||||
let line = serde_json::to_string(&req).unwrap();
|
||||
assert!(line.contains("\"method\":\"send_redact\""), "wire: {line}");
|
||||
let back: DaemonRequest = serde_json::from_str(&line).unwrap();
|
||||
match back.op {
|
||||
DaemonOp::SendRedact {
|
||||
room,
|
||||
event_id,
|
||||
reason,
|
||||
} => {
|
||||
assert_eq!(room, "!r:s");
|
||||
assert_eq!(event_id, "$e");
|
||||
assert_eq!(reason.as_deref(), Some("spam"));
|
||||
}
|
||||
other => panic!("wrong variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_redact_round_trips_without_reason() {
|
||||
let req = DaemonRequest {
|
||||
account: None,
|
||||
op: DaemonOp::SendRedact {
|
||||
room: "!r:s".to_owned(),
|
||||
event_id: "$e".to_owned(),
|
||||
reason: None,
|
||||
},
|
||||
};
|
||||
let line = serde_json::to_string(&req).unwrap();
|
||||
let back: DaemonRequest = serde_json::from_str(&line).unwrap();
|
||||
match back.op {
|
||||
DaemonOp::SendRedact { reason, .. } => assert_eq!(reason, None),
|
||||
other => panic!("wrong variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue