add send_reply and get_room_history mcp tools, show reply targets in prompt

This commit is contained in:
Damocles 2026-05-01 03:12:52 +02:00
parent 41da93a71f
commit 3f5208cab1
5 changed files with 190 additions and 3 deletions

View file

@ -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<usize>,
},
}
#[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<usize>,
}
// ---------------------------------------------------------------------------
// 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<SendReplyParams>,
) -> Result<CallToolResult, McpError> {
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<GetRoomHistoryParams>,
) -> Result<CallToolResult, McpError> {
let resp = self
.call(&DaemonRequest::GetRoomHistory {
room_id: params.room_id,
limit: params.limit,
})
.await?;
Self::response_to_result(resp)
}
}
// ---------------------------------------------------------------------------

View file

@ -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",

View file

@ -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<usize>,
},
}
/// Response from daemon to MCP server.

View file

@ -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::<OwnedRoomId>() {
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<usize>,
) -> DaemonResponse {
let rid = match room_id.parse::<OwnedRoomId>() {
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)
}

View file

@ -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();
}