replace === output parsing with MCP tools via rmcp

This commit is contained in:
Damocles 2026-05-01 02:38:13 +02:00
parent 09259ee5fa
commit 9354837830
8 changed files with 761 additions and 437 deletions

185
src/socket.rs Normal file
View file

@ -0,0 +1,185 @@
use std::path::Path;
use matrix_sdk::{
Client,
ruma::{
OwnedRoomId, OwnedUserId,
events::{
reaction::ReactionEventContent,
relation::Annotation,
room::message::RoomMessageEventContent,
},
},
};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use crate::handlers;
use crate::protocol::{DaemonRequest, DaemonResponse};
use crate::timeline;
pub async fn start_listener(socket_path: &Path, client: Client) -> anyhow::Result<()> {
let _ = tokio::fs::remove_file(socket_path).await;
let listener = UnixListener::bind(socket_path)?;
tracing::info!(path = %socket_path.display(), "mcp socket listener started");
loop {
let (stream, _) = listener.accept().await?;
let client = client.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, client).await {
tracing::warn!("mcp socket connection error: {e}");
}
});
}
}
async fn handle_connection(stream: UnixStream, client: Client) -> anyhow::Result<()> {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Some(line) = lines.next_line().await? {
let response = match serde_json::from_str::<DaemonRequest>(&line) {
Ok(request) => {
tracing::debug!(?request, "mcp socket request");
handle_request(request, &client).await
}
Err(e) => DaemonResponse::err(format!("invalid request: {e}")),
};
tracing::debug!(?response, "mcp socket response");
let mut json = serde_json::to_string(&response)?;
json.push('\n');
writer.write_all(json.as_bytes()).await?;
writer.flush().await?;
}
Ok(())
}
async fn handle_request(request: DaemonRequest, client: &Client) -> DaemonResponse {
match request {
DaemonRequest::SendMessage { room_id, body } => send_message(client, &room_id, &body).await,
DaemonRequest::SendDm { user_id, body } => send_dm(client, &user_id, &body).await,
DaemonRequest::SendReaction {
room_id,
event_id,
key,
} => send_reaction(client, &room_id, &event_id, &key).await,
DaemonRequest::ListRooms {} => list_rooms(client).await,
DaemonRequest::ListRoomMembers { room_id } => list_room_members(client, &room_id).await,
}
}
async fn send_message(client: &Client, room_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 content = RoomMessageEventContent::text_plain(body);
match room.send(content).await {
Ok(_) => {
tracing::info!(room = %rid, "mcp: sent message");
DaemonResponse::ok(format!("sent to {rid}"))
}
Err(e) => DaemonResponse::err(format!("send failed: {e}")),
}
}
async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonResponse {
let uid = match user_id.parse::<OwnedUserId>() {
Ok(u) => u,
Err(e) => return DaemonResponse::err(format!("invalid user_id: {e}")),
};
let room = match handlers::find_or_create_dm(client, &uid).await {
Ok(r) => r,
Err(e) => return DaemonResponse::err(format!("failed to get/create DM: {e}")),
};
let content = RoomMessageEventContent::text_plain(body);
match room.send(content).await {
Ok(_) => {
tracing::info!(user = %uid, "mcp: sent DM");
DaemonResponse::ok(format!("DM sent to {uid}"))
}
Err(e) => DaemonResponse::err(format!("send DM failed: {e}")),
}
}
async fn send_reaction(
client: &Client,
room_id: &str,
event_id: &str,
key: &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()),
};
// Load timeline to resolve possibly-shortened event id
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 = ReactionEventContent::new(Annotation::new(full_eid.clone(), key.to_owned()));
match room.send(content).await {
Ok(_) => {
tracing::info!(target = %full_eid, %key, "mcp: sent reaction");
DaemonResponse::ok(format!("reacted {key} to {full_eid}"))
}
Err(e) => DaemonResponse::err(format!("send reaction failed: {e}")),
}
}
async fn list_rooms(client: &Client) -> DaemonResponse {
let mut rooms = Vec::new();
for room in client.joined_rooms() {
let name = room
.display_name()
.await
.map_or_else(|_| room.room_id().to_string(), |n| n.to_string());
rooms.push(json!({
"room_id": room.room_id().as_str(),
"name": name,
}));
}
DaemonResponse::ok(rooms)
}
async fn list_room_members(client: &Client, room_id: &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 members = match room.members(matrix_sdk::RoomMemberships::JOIN).await {
Ok(m) => m,
Err(e) => return DaemonResponse::err(format!("failed to list members: {e}")),
};
let list: Vec<_> = members
.iter()
.map(|m| {
json!({
"user_id": m.user_id().as_str(),
"display_name": m.display_name().unwrap_or_default(),
})
})
.collect();
DaemonResponse::ok(list)
}