add send_reply and get_room_history mcp tools, show reply targets in prompt
This commit is contained in:
parent
41da93a71f
commit
3f5208cab1
5 changed files with 190 additions and 3 deletions
|
|
@ -40,11 +40,24 @@ mod protocol_inline {
|
||||||
key: String,
|
key: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[serde(rename = "send_reply")]
|
||||||
|
SendReply {
|
||||||
|
room_id: String,
|
||||||
|
event_id: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
#[serde(rename = "list_rooms")]
|
#[serde(rename = "list_rooms")]
|
||||||
ListRooms {},
|
ListRooms {},
|
||||||
|
|
||||||
#[serde(rename = "list_room_members")]
|
#[serde(rename = "list_room_members")]
|
||||||
ListRoomMembers { room_id: String },
|
ListRoomMembers { room_id: String },
|
||||||
|
|
||||||
|
#[serde(rename = "get_room_history")]
|
||||||
|
GetRoomHistory {
|
||||||
|
room_id: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
@ -94,6 +107,24 @@ struct ListRoomMembersParams {
|
||||||
room_id: String,
|
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
|
// MCP server struct
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -221,6 +252,35 @@ impl MatrixBridge {
|
||||||
.await?;
|
.await?;
|
||||||
Self::response_to_result(resp)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ pub async fn invoke_claude(
|
||||||
"--add-dir",
|
"--add-dir",
|
||||||
&identity_str,
|
&identity_str,
|
||||||
"--allowedTools",
|
"--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",
|
||||||
&mcp_config_str,
|
&mcp_config_str,
|
||||||
"-p",
|
"-p",
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,24 @@ pub enum DaemonRequest {
|
||||||
key: String,
|
key: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[serde(rename = "send_reply")]
|
||||||
|
SendReply {
|
||||||
|
room_id: String,
|
||||||
|
event_id: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
#[serde(rename = "list_rooms")]
|
#[serde(rename = "list_rooms")]
|
||||||
ListRooms {},
|
ListRooms {},
|
||||||
|
|
||||||
#[serde(rename = "list_room_members")]
|
#[serde(rename = "list_room_members")]
|
||||||
ListRoomMembers { room_id: String },
|
ListRoomMembers { room_id: String },
|
||||||
|
|
||||||
|
#[serde(rename = "get_room_history")]
|
||||||
|
GetRoomHistory {
|
||||||
|
room_id: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response from daemon to MCP server.
|
/// Response from daemon to MCP server.
|
||||||
|
|
|
||||||
110
src/socket.rs
110
src/socket.rs
|
|
@ -2,6 +2,7 @@ use std::path::Path;
|
||||||
|
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
Client,
|
Client,
|
||||||
|
room::reply::{EnforceThread, Reply},
|
||||||
ruma::{
|
ruma::{
|
||||||
OwnedRoomId, OwnedUserId,
|
OwnedRoomId, OwnedUserId,
|
||||||
events::{
|
events::{
|
||||||
|
|
@ -66,8 +67,16 @@ async fn handle_request(request: DaemonRequest, client: &Client) -> DaemonRespon
|
||||||
event_id,
|
event_id,
|
||||||
key,
|
key,
|
||||||
} => send_reaction(client, &room_id, &event_id, &key).await,
|
} => 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::ListRooms {} => list_rooms(client).await,
|
||||||
DaemonRequest::ListRoomMembers { room_id } => list_room_members(client, &room_id).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();
|
.collect();
|
||||||
DaemonResponse::ok(list)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,15 @@ pub fn render_timeline_item(
|
||||||
body,
|
body,
|
||||||
is_self,
|
is_self,
|
||||||
ts,
|
ts,
|
||||||
..
|
in_reply_to,
|
||||||
} => {
|
} => {
|
||||||
let ts_str = format_ts(*ts);
|
let ts_str = format_ts(*ts);
|
||||||
let id = short_event_id(event_id);
|
let id = short_event_id(event_id);
|
||||||
let prefix = if *is_self { "(you) " } else { "" };
|
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) {
|
let readers_str = match read_markers.get(event_id) {
|
||||||
Some(rs) if !rs.is_empty() => {
|
Some(rs) if !rs.is_empty() => {
|
||||||
let mut sorted = rs.clone();
|
let mut sorted = rs.clone();
|
||||||
|
|
@ -111,7 +115,7 @@ pub fn render_timeline_item(
|
||||||
};
|
};
|
||||||
writeln!(
|
writeln!(
|
||||||
prompt,
|
prompt,
|
||||||
"[{ts_str}] {id} {prefix}{sender}: {body}{readers_str}"
|
"[{ts_str}] {id} {prefix}{sender}:{reply_str} {body}{readers_str}"
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue