matrix: hive-matrix-mcp crate (daemon+stdio bridge) + harness wiring (#548 phase 3)
This commit is contained in:
parent
23ed124881
commit
e6c53045ad
16 changed files with 3310 additions and 35 deletions
264
hive-matrix-mcp/src/bin/mcp.rs
Normal file
264
hive-matrix-mcp/src/bin/mcp.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
//! `hive-matrix-mcp` binary — stdio MCP server claude spawns per turn.
|
||||
//! Thin protocol bridge: every tool call → connect to the daemon's
|
||||
//! unix socket → write a JSON request line → read the JSON response →
|
||||
//! return the payload (or the error message) to claude.
|
||||
//!
|
||||
//! No matrix-sdk dep at this entrypoint. The daemon owns the heavy
|
||||
//! 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.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rmcp::{
|
||||
ServerHandler, ServiceExt,
|
||||
handler::server::wrapper::Parameters,
|
||||
schemars::{self, JsonSchema},
|
||||
tool, tool_handler, tool_router,
|
||||
transport::stdio,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
use hive_matrix_mcp::paths;
|
||||
use hive_matrix_mcp::protocol::{DaemonRequest, DaemonResponse};
|
||||
|
||||
/// Send `req` to the daemon and read back the response. Each call is a
|
||||
/// fresh unix-socket connection — short-lived (the daemon dispatch is
|
||||
/// a single round-trip) so connection pooling would be over-engineering.
|
||||
async fn round_trip(req: DaemonRequest) -> Result<DaemonResponse> {
|
||||
let socket = paths::daemon_socket();
|
||||
let stream = UnixStream::connect(&socket)
|
||||
.await
|
||||
.with_context(|| format!("connect daemon socket {}", socket.display()))?;
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut line = serde_json::to_string(&req)?;
|
||||
line.push('\n');
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("write request to daemon socket")?;
|
||||
writer.shutdown().await.ok();
|
||||
let mut buf = String::new();
|
||||
BufReader::new(reader)
|
||||
.read_line(&mut buf)
|
||||
.await
|
||||
.context("read response from daemon socket")?;
|
||||
serde_json::from_str(&buf).context("parse daemon response")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn render(resp: Result<DaemonResponse>) -> String {
|
||||
match resp {
|
||||
Ok(DaemonResponse::Ok { payload }) => {
|
||||
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string())
|
||||
}
|
||||
Ok(DaemonResponse::Error { message }) => format!("matrix error: {message}"),
|
||||
Err(e) => format!("matrix bridge error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct SendMessageArgs {
|
||||
/// Matrix room id (`!abc:server`) or canonical alias (`#name:server`).
|
||||
/// Aliases are server-resolved at send time.
|
||||
room: String,
|
||||
/// Message body. Markdown is rendered to HTML by the daemon
|
||||
/// (`text_markdown`); plain text passes through unchanged.
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct SendDmArgs {
|
||||
/// Matrix user id of the recipient (`@user:server`). DM room is
|
||||
/// created if one doesn't already exist between this agent and
|
||||
/// the recipient.
|
||||
user_id: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct SendReactionArgs {
|
||||
room: String,
|
||||
/// Full matrix event id of the message being reacted to.
|
||||
event_id: String,
|
||||
/// Reaction key — usually an emoji (`👍`, `❤️`) but any string
|
||||
/// works per matrix spec.
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct SendReplyArgs {
|
||||
room: String,
|
||||
event_id: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct MarkReadArgs {
|
||||
room: String,
|
||||
event_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ListRoomsArgs {}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ListRoomMembersArgs {
|
||||
room: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ReadRoomArgs {
|
||||
room: String,
|
||||
/// Maximum events to return (default 50, max 200). Newest first.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
struct MatrixBridge {
|
||||
#[allow(dead_code)]
|
||||
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl MatrixBridge {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl MatrixBridge {
|
||||
#[tool(
|
||||
description = "Post a plain-text or markdown message to a matrix room. \
|
||||
`room` is either a room id (!abc:server) or alias (#name:server). \
|
||||
Returns the new event id."
|
||||
)]
|
||||
async fn send_message(&self, Parameters(args): Parameters<SendMessageArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::SendMessage {
|
||||
room: args.room,
|
||||
body: args.body,
|
||||
}).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Open (or reuse) a direct message room with `user_id` \
|
||||
(@user:server) and post `body` to it."
|
||||
)]
|
||||
async fn send_dm(&self, Parameters(args): Parameters<SendDmArgs>) -> String {
|
||||
render(round_trip(DaemonRequest::SendDm {
|
||||
user_id: args.user_id,
|
||||
body: args.body,
|
||||
}).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "React to a specific matrix event with an emoji or short \
|
||||
string `key`. Matrix-spec annotation; renders as a reaction in \
|
||||
standard clients."
|
||||
)]
|
||||
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,
|
||||
}).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Reply to a specific matrix event in a room, threaded \
|
||||
via m.in_reply_to. Returns the reply's event id."
|
||||
)]
|
||||
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,
|
||||
}).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Mark a specific event as read for this agent. Updates \
|
||||
the room's unread indicator + sends a read receipt other \
|
||||
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,
|
||||
}).await)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
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)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "List the members of a matrix room (joined-state only). \
|
||||
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)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Read the most recent N events from a matrix room \
|
||||
(default 50, max 200). Returns each event's id, sender, timestamp, \
|
||||
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,
|
||||
}).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(
|
||||
instructions = "Matrix client for an agent on a hyperhive swarm. Use \
|
||||
`send_message` to post in a joined room, `send_dm` to message a \
|
||||
specific user, `send_reaction` to react with an emoji, `send_reply` \
|
||||
to thread a reply, `mark_read` to acknowledge an event. Discover \
|
||||
rooms with `list_rooms`, members with `list_room_members`, recent \
|
||||
timeline with `read_room`. Room references accept ids (!abc:server) \
|
||||
or aliases (#name:server); user references use @user:server."
|
||||
)]
|
||||
impl ServerHandler for MatrixBridge {}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
// Standalone-degraded boot: missing daemon socket → exit 0
|
||||
// cleanly so claude doesn't see an MCP startup error when matrix
|
||||
// hasn't been provisioned for this agent yet. The daemon will
|
||||
// appear once hive-c0re writes the token + systemd starts the
|
||||
// daemon unit.
|
||||
let socket = paths::daemon_socket();
|
||||
if !tokio::fs::try_exists(&socket).await.unwrap_or(false) {
|
||||
tracing::warn!(
|
||||
path = %socket.display(),
|
||||
"matrix daemon socket absent; exiting cleanly so MCP startup doesn't fail"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let bridge = MatrixBridge::new();
|
||||
let service = bridge.serve(stdio()).await.context("serve MCP over stdio")?;
|
||||
service.waiting().await.context("MCP service exited unexpectedly")?;
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in a new issue