diff --git a/src/bin/mcp.rs b/src/bin/mcp.rs index ac18b69..def1cf3 100644 --- a/src/bin/mcp.rs +++ b/src/bin/mcp.rs @@ -40,11 +40,24 @@ mod protocol_inline { key: String, }, + #[serde(rename = "send_reply")] + SendReply { + room_id: String, + event_id: String, + body: String, + }, + #[serde(rename = "list_rooms")] ListRooms {}, #[serde(rename = "list_room_members")] ListRoomMembers { room_id: String }, + + #[serde(rename = "get_room_history")] + GetRoomHistory { + room_id: String, + limit: Option, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -94,6 +107,24 @@ struct ListRoomMembersParams { room_id: String, } +#[derive(Debug, Deserialize, JsonSchema)] +struct SendReplyParams { + /// The event ID to reply to. Can be the shortened form shown in the + /// timeline (e.g. $abc123de...). + event_id: String, + /// The reply text. + body: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct GetRoomHistoryParams { + /// The room ID to fetch history for. + room_id: String, + /// Number of recent timeline items to return. Default 20, max 100. + #[serde(default)] + limit: Option, +} + // --------------------------------------------------------------------------- // MCP server struct // --------------------------------------------------------------------------- @@ -221,6 +252,35 @@ impl MatrixBridge { .await?; Self::response_to_result(resp) } + + #[tool(description = "Reply to a specific message in the source room. Threads via m.in_reply_to relation so clients render the quote.")] + async fn send_reply( + &self, + Parameters(params): Parameters, + ) -> Result { + let resp = self + .call(&DaemonRequest::SendReply { + room_id: self.source_room.clone(), + event_id: params.event_id, + body: params.body, + }) + .await?; + Self::response_to_result(resp) + } + + #[tool(description = "Get recent message history for any joined room. Returns JSON list of messages and reactions with timestamps.")] + async fn get_room_history( + &self, + Parameters(params): Parameters, + ) -> Result { + let resp = self + .call(&DaemonRequest::GetRoomHistory { + room_id: params.room_id, + limit: params.limit, + }) + .await?; + Self::response_to_result(resp) + } } // --------------------------------------------------------------------------- diff --git a/src/claude.rs b/src/claude.rs index 4499ab0..dc44b7a 100644 --- a/src/claude.rs +++ b/src/claude.rs @@ -58,7 +58,7 @@ pub async fn invoke_claude( "--add-dir", &identity_str, "--allowedTools", - "Read,Edit,Write,Glob,Grep,mcp__matrix__send_message,mcp__matrix__send_dm,mcp__matrix__send_reaction,mcp__matrix__list_rooms,mcp__matrix__list_room_members", + "Read,Edit,Write,Glob,Grep,mcp__matrix__send_message,mcp__matrix__send_dm,mcp__matrix__send_reaction,mcp__matrix__send_reply,mcp__matrix__list_rooms,mcp__matrix__list_room_members,mcp__matrix__get_room_history", "--mcp-config", &mcp_config_str, "-p", diff --git a/src/protocol.rs b/src/protocol.rs index fb67d4e..eb426f4 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -17,11 +17,24 @@ pub enum DaemonRequest { key: String, }, + #[serde(rename = "send_reply")] + SendReply { + room_id: String, + event_id: String, + body: String, + }, + #[serde(rename = "list_rooms")] ListRooms {}, #[serde(rename = "list_room_members")] ListRoomMembers { room_id: String }, + + #[serde(rename = "get_room_history")] + GetRoomHistory { + room_id: String, + limit: Option, + }, } /// Response from daemon to MCP server. diff --git a/src/socket.rs b/src/socket.rs index 436c653..1f0380f 100644 --- a/src/socket.rs +++ b/src/socket.rs @@ -2,6 +2,7 @@ use std::path::Path; use matrix_sdk::{ Client, + room::reply::{EnforceThread, Reply}, ruma::{ OwnedRoomId, OwnedUserId, events::{ @@ -66,8 +67,16 @@ async fn handle_request(request: DaemonRequest, client: &Client) -> DaemonRespon event_id, key, } => send_reaction(client, &room_id, &event_id, &key).await, + DaemonRequest::SendReply { + room_id, + event_id, + body, + } => send_reply(client, &room_id, &event_id, &body).await, DaemonRequest::ListRooms {} => list_rooms(client).await, DaemonRequest::ListRoomMembers { room_id } => list_room_members(client, &room_id).await, + DaemonRequest::GetRoomHistory { room_id, limit } => { + get_room_history(client, &room_id, limit).await + } } } @@ -183,3 +192,104 @@ async fn list_room_members(client: &Client, room_id: &str) -> DaemonResponse { .collect(); DaemonResponse::ok(list) } + +async fn send_reply(client: &Client, room_id: &str, event_id: &str, body: &str) -> DaemonResponse { + let rid = match room_id.parse::() { + Ok(r) => r, + Err(e) => return DaemonResponse::err(format!("invalid room_id: {e}")), + }; + let Some(room) = client.get_room(&rid) else { + return DaemonResponse::err(format!("room {rid} not found")); + }; + let own_user = match client.user_id() { + Some(u) => u.to_owned(), + None => return DaemonResponse::err("not logged in".to_owned()), + }; + + // Resolve possibly-shortened event id against recent timeline + let tl = match timeline::load_timeline(&room, 50, &own_user).await { + Ok(t) => t, + Err(e) => return DaemonResponse::err(format!("failed to load timeline: {e}")), + }; + let Some(full_eid) = timeline::resolve_event_id(&tl, event_id) else { + return DaemonResponse::err(format!("event {event_id} not found in timeline")); + }; + + let content = RoomMessageEventContent::text_plain(body).into(); + let reply = Reply { + event_id: full_eid.clone(), + enforce_thread: EnforceThread::MaybeThreaded, + }; + let reply_content = match room.make_reply_event(content, reply).await { + Ok(c) => c, + Err(e) => return DaemonResponse::err(format!("make_reply_event failed: {e}")), + }; + match room.send(reply_content).await { + Ok(_) => { + tracing::info!(target = %full_eid, "mcp: sent reply"); + DaemonResponse::ok(format!("replied to {full_eid}")) + } + Err(e) => DaemonResponse::err(format!("send reply failed: {e}")), + } +} + +async fn get_room_history( + client: &Client, + room_id: &str, + limit: Option, +) -> DaemonResponse { + let rid = match room_id.parse::() { + Ok(r) => r, + Err(e) => return DaemonResponse::err(format!("invalid room_id: {e}")), + }; + let Some(room) = client.get_room(&rid) else { + return DaemonResponse::err(format!("room {rid} not found")); + }; + let own_user = match client.user_id() { + Some(u) => u.to_owned(), + None => return DaemonResponse::err("not logged in".to_owned()), + }; + let limit = limit.unwrap_or(20).min(100); + + let tl = match timeline::load_timeline(&room, limit, &own_user).await { + Ok(t) => t, + Err(e) => return DaemonResponse::err(format!("failed to load timeline: {e}")), + }; + + let items: Vec<_> = tl + .iter() + .map(|item| match item { + crate::types::TimelineItem::Message { + event_id, + sender, + body, + is_self, + ts, + in_reply_to, + } => json!({ + "kind": "message", + "event_id": event_id.as_str(), + "sender": sender.as_str(), + "body": body, + "is_self": is_self, + "ts": ts, + "in_reply_to": in_reply_to.as_ref().map(|e| e.as_str()), + }), + crate::types::TimelineItem::Reaction { + sender, + target_event_id, + key, + is_self, + ts, + } => json!({ + "kind": "reaction", + "sender": sender.as_str(), + "target_event_id": target_event_id.as_str(), + "key": key, + "is_self": is_self, + "ts": ts, + }), + }) + .collect(); + DaemonResponse::ok(items) +} diff --git a/src/timeline.rs b/src/timeline.rs index c9cfedc..742adba 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -95,11 +95,15 @@ pub fn render_timeline_item( body, is_self, ts, - .. + in_reply_to, } => { let ts_str = format_ts(*ts); let id = short_event_id(event_id); let prefix = if *is_self { "(you) " } else { "" }; + let reply_str = match in_reply_to { + Some(target) => format!(" [reply to {}]", short_event_id(target)), + None => String::new(), + }; let readers_str = match read_markers.get(event_id) { Some(rs) if !rs.is_empty() => { let mut sorted = rs.clone(); @@ -111,7 +115,7 @@ pub fn render_timeline_item( }; writeln!( prompt, - "[{ts_str}] {id} {prefix}{sender}: {body}{readers_str}" + "[{ts_str}] {id} {prefix}{sender}:{reply_str} {body}{readers_str}" ) .unwrap(); }