From ef461797adcd37e477fc2a0247e456a5af911aa8 Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 1 May 2026 04:20:56 +0200 Subject: [PATCH] markdown formatting in messages, sharper tool descriptions --- Cargo.lock | 25 +++++++++++++++++++ Cargo.toml | 2 +- src/bin/mcp.rs | 67 +++++++++++++++++++++++++++----------------------- src/socket.rs | 6 ++--- 4 files changed, 65 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 911bfb3..94441ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2623,6 +2623,24 @@ dependencies = [ "syn", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quote" version = "1.0.45" @@ -3011,6 +3029,7 @@ dependencies = [ "js_int", "js_option", "percent-encoding", + "pulldown-cmark", "regex", "ruma-common", "ruma-identifiers-validation", @@ -4130,6 +4149,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 54318c0..cf4d52a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ pedantic = { level = "warn", priority = -1 } module_name_repetitions = "allow" [dependencies] -matrix-sdk = { version = "0.14", features = ["e2e-encryption", "sqlite"] } +matrix-sdk = { version = "0.14", features = ["e2e-encryption", "sqlite", "markdown"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/bin/mcp.rs b/src/bin/mcp.rs index 85e40e5..05fc46a 100644 --- a/src/bin/mcp.rs +++ b/src/bin/mcp.rs @@ -83,69 +83,74 @@ use protocol_inline::{DaemonRequest, DaemonResponse}; #[derive(Debug, Deserialize, JsonSchema)] struct SendMessageParams { - /// The message text to send. + /// Message text. Plain text - markdown isn't specially rendered. Keep it + /// short, you're rate-limited and terse is on-character. body: String, - /// Target room ID (e.g. !abc:server). Defaults to the room that triggered - /// this invocation if omitted. + /// Target room ID like `!abc:server.com`. Omit to send to the room that + /// triggered this invocation (the common case). #[serde(default)] room_id: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct SendDmParams { - /// The Matrix user ID to DM (e.g. @alice:server). + /// Full Matrix user ID like `@alice:server.com`. Must include the leading + /// `@` and the server suffix. user_id: String, - /// The message text to send. + /// DM text. Plain text. body: String, } #[derive(Debug, Deserialize, JsonSchema)] struct SendReactionParams { - /// The event ID to react to. Can be the shortened form shown in the - /// timeline (e.g. $abc123de...). + /// Event ID of the message to react to. Shortened form from the prompt + /// (`$abc12345…`) is fine - resolved by prefix match against recent + /// timeline. event_id: String, - /// The reaction emoji (e.g. fire, eyes, heart). + /// The actual emoji character to react with, e.g. `🔥` or `👀` or `❤️`. + /// Not a keyword name like "fire". key: String, } #[derive(Debug, Deserialize, JsonSchema)] struct ListRoomMembersParams { - /// The room ID to list members for. + /// Room ID like `!abc:server.com`. 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 of the message you're replying to. Shortened form from the + /// prompt (`$abc12345…`) is fine. event_id: String, - /// The reply text. + /// Reply text. Matrix clients render the original above as a quote, so + /// don't repeat its content - just reply. body: String, } #[derive(Debug, Deserialize, JsonSchema)] struct GetRoomHistoryParams { - /// The room ID to fetch history for. + /// Room ID like `!abc:server.com`. room_id: String, - /// Number of recent timeline items to return. Default 20, max 100. + /// How many timeline items to return (oldest-first, most recent included). + /// Default 20, max 100. Backfills from the homeserver if the local cache + /// is short. #[serde(default)] limit: Option, } #[derive(Debug, Deserialize, JsonSchema)] struct FetchEventParams { - /// The event ID to fetch. Can be the shortened form shown in the - /// timeline (e.g. $abc123de...) or a full ID. Use this to dereference - /// reply targets shown as [reply to $abc...] in the prompt, or to look - /// up any specific event by ID. + /// Event ID to fetch. Shortened form from the prompt (`$abc12345…`) or a + /// full ID. Use this to dereference `[reply to $...]` markers or any + /// event_id referenced in chat that isn't in your current window. event_id: String, - /// Number of context messages BEFORE the target to include. Default 0. - /// Pass 5-10 for conversational context. The response also includes an - /// `earlier_handle` event_id you can pass to fetch_event to page further - /// back into history. + /// How many messages BEFORE the target to include for conversational + /// context. Default 0, max 50. Pass 5-10 for typical "what was the + /// conversation around this". #[serde(default)] context_before: Option, - /// Room ID to look in. Defaults to source room if omitted. + /// Room to look in. Omit for the source room (the common case). #[serde(default)] room_id: Option, } @@ -213,7 +218,7 @@ impl MatrixBridge { #[tool_router(server_handler)] impl MatrixBridge { - #[tool(description = "Send a message to a Matrix room. Defaults to the room that triggered this invocation.")] + #[tool(description = "Send a top-level message to a Matrix room. The default target is the room that triggered this invocation. For replies to a specific message in a busy room, use send_reply instead. For private 1:1, use send_dm.")] async fn send_message( &self, Parameters(params): Parameters, @@ -230,7 +235,7 @@ impl MatrixBridge { Self::response_to_result(resp) } - #[tool(description = "Send a direct message to a Matrix user. Creates the DM room if needed.")] + #[tool(description = "Send a private direct message to a Matrix user. Reuses an existing DM room with that user, or creates a new one. Works even if you don't currently share any room with them. Use for private 1:1 - in a room channel, prefer send_message.")] async fn send_dm( &self, Parameters(params): Parameters, @@ -244,7 +249,7 @@ impl MatrixBridge { Self::response_to_result(resp) } - #[tool(description = "React to a message with an emoji. Use the event ID shown in the timeline.")] + #[tool(description = "React to a specific message in the source room with an emoji. Lower friction than a reply when you just want to acknowledge or signal. Reactions are visible to everyone in the room.")] async fn send_reaction( &self, Parameters(params): Parameters, @@ -259,13 +264,13 @@ impl MatrixBridge { Self::response_to_result(resp) } - #[tool(description = "List all Matrix rooms the bot has joined.")] + #[tool(description = "List all Matrix rooms the bot has joined, with display names. Returns JSON array of {room_id, name}. Use to find a room ID for cross-room tools (get_room_history, send_message with room_id).")] async fn list_rooms(&self) -> Result { let resp = self.call(&DaemonRequest::ListRooms {}).await?; Self::response_to_result(resp) } - #[tool(description = "List members of a Matrix room.")] + #[tool(description = "List currently joined members of a Matrix room. Returns JSON array of {user_id, display_name}. Useful for finding a user_id to DM, or for confirming who's actually in a room.")] async fn list_room_members( &self, Parameters(params): Parameters, @@ -278,7 +283,7 @@ impl MatrixBridge { 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.")] + #[tool(description = "Reply to a specific message in the source room with proper m.in_reply_to threading. Matrix clients render the original message as a quote above your reply, so don't repeat its content. Use this when there are multiple parallel conversations and a top-level message would be ambiguous - otherwise prefer send_message.")] async fn send_reply( &self, Parameters(params): Parameters, @@ -293,7 +298,7 @@ impl MatrixBridge { Self::response_to_result(resp) } - #[tool(description = "Get recent message history for any joined room. Returns JSON list of messages and reactions with timestamps. Backfills via /messages if cache is short.")] + #[tool(description = "Fetch recent timeline (messages + reactions, oldest-first) for any joined room. Backfills from the homeserver if the local cache is short. For looking up ONE specific event by ID, use fetch_event - it's lighter and gives you context around the target.")] async fn get_room_history( &self, Parameters(params): Parameters, @@ -307,7 +312,7 @@ impl MatrixBridge { Self::response_to_result(resp) } - #[tool(description = "Fetch a specific event by ID with optional context messages before it. Use to dereference [reply to $...] markers or arbitrary event IDs. Response includes earlier_handle for paging further back.")] + #[tool(description = "Look up an event by ID with optional N messages of context before it. Bypasses the local cache via the homeserver /context endpoint - works even for events older than your timeline window. TWO MAIN USES: (1) dereference `[reply to $...]` markers in the prompt so you can see what someone is actually responding to. (2) page through historical context: pass `context_before` (e.g. 10), get back the events plus an `earlier_handle` event_id - call fetch_event again with that handle to walk further back, and keep chaining to read arbitrarily far into the past.")] async fn fetch_event( &self, Parameters(params): Parameters, diff --git a/src/socket.rs b/src/socket.rs index 08f1e70..fbaf5ab 100644 --- a/src/socket.rs +++ b/src/socket.rs @@ -93,7 +93,7 @@ async fn send_message(client: &Client, room_id: &str, body: &str) -> DaemonRespo let Some(room) = client.get_room(&rid) else { return DaemonResponse::err(format!("room {rid} not found")); }; - let content = RoomMessageEventContent::text_plain(body); + let content = RoomMessageEventContent::text_markdown(body); match room.send(content).await { Ok(_) => { tracing::info!(room = %rid, "mcp: sent message"); @@ -112,7 +112,7 @@ async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonResponse { Ok(r) => r, Err(e) => return DaemonResponse::err(format!("failed to get/create DM: {e}")), }; - let content = RoomMessageEventContent::text_plain(body); + let content = RoomMessageEventContent::text_markdown(body); match room.send(content).await { Ok(_) => { tracing::info!(user = %uid, "mcp: sent DM"); @@ -220,7 +220,7 @@ async fn send_reply(client: &Client, room_id: &str, event_id: &str, body: &str) return DaemonResponse::err(format!("event {event_id} not found in timeline")); }; - let content = RoomMessageEventContent::text_plain(body).into(); + let content = RoomMessageEventContent::text_markdown(body).into(); let reply = Reply { event_id: full_eid.clone(), enforce_thread: EnforceThread::MaybeThreaded,