hyperhive/hive-matrix-mcp/src/bin/mcp.rs

379 lines
14 KiB
Rust

//! `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, 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
/// 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>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ListInvitesArgs {}
#[derive(Debug, Deserialize, JsonSchema)]
struct JoinRoomArgs {
/// Matrix room id (`!abc:server`) or canonical alias (`#name:server`).
room: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ResolveInviteArgs {
/// Matrix room id (`!abc:server`) or alias (`#name:server`) you have
/// a pending invite to.
room: String,
/// What to do with the invite: `"accept"` (join the room) or
/// `"reject"` (decline and leave).
action: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct InviteUserArgs {
/// Matrix room id (`!abc:server`) or canonical alias (`#name:server`)
/// to invite the user into. You must already be a member.
room: String,
/// Matrix user id of the invitee (`@user:server`).
user_id: String,
}
struct MatrixBridge {
#[allow(
dead_code,
reason = "populated by the #[tool_router] macro; the generated \
ServerHandler wiring consumes it, the field is never read directly"
)]
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. Rejected with a hint if the room still has \
unread messages — read_room then mark_read the latest event first so \
you don't talk over messages you haven't seen."
)]
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. If the DM room already exists \
and has unread messages, the send is rejected with a hint — read_room \
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,
})
.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. Rejected with a hint \
if the room still has unread messages — read_room then mark_read the \
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,
})
.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 rooms this agent has been invited to but not yet joined. \
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)
}
#[tool(
description = "Join a matrix room by id (!abc:server) or alias (#name:server). \
Also accepts a pending invite if one exists, but for invites prefer \
`resolve_invite` (which can also reject). Use `join_room` to join a \
public room you weren't invited to. After joining the room appears in \
`list_rooms`."
)]
async fn join_room(&self, Parameters(args): Parameters<JoinRoomArgs>) -> String {
render(round_trip(DaemonRequest::JoinRoom { room: args.room }).await)
}
#[tool(
description = "Accept or reject a pending matrix invite. `room` is a room id \
(!abc:server) or alias (#name:server) you were invited to; `action` is \
\"accept\" (join the room) or \"reject\" (decline and leave). See pending \
invites with `list_invites`."
)]
async fn resolve_invite(&self, Parameters(args): Parameters<ResolveInviteArgs>) -> String {
let action = match args.action.trim().to_ascii_lowercase().as_str() {
"accept" => InviteAction::Accept,
"reject" => InviteAction::Reject,
other => {
return format!(
"matrix error: invalid action {other:?} — use \"accept\" or \"reject\""
);
}
};
render(
round_trip(DaemonRequest::ResolveInvite {
room: args.room,
action,
})
.await,
)
}
#[tool(
description = "Invite a user to a matrix room you're already in. `room` is a \
room id (!abc:server) or alias (#name:server); `user_id` is the invitee \
(@user:server). You must have a high enough power level in the room to \
invite. The invitee then sees a pending invite they accept with `join_room`."
)]
async fn invite_user(&self, Parameters(args): Parameters<InviteUserArgs>) -> String {
render(
round_trip(DaemonRequest::InviteUser {
room: args.room,
user_id: args.user_id,
})
.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`. 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.")]
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(())
}