markdown formatting in messages, sharper tool descriptions

This commit is contained in:
Damocles 2026-05-01 04:20:56 +02:00
parent 829a60854f
commit ef461797ad
4 changed files with 65 additions and 35 deletions

25
Cargo.lock generated
View file

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

View file

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

View file

@ -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<String>,
}
#[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<usize>,
}
#[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<u32>,
/// 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<String>,
}
@ -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<SendMessageParams>,
@ -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<SendDmParams>,
@ -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<SendReactionParams>,
@ -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<CallToolResult, McpError> {
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<ListRoomMembersParams>,
@ -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<SendReplyParams>,
@ -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<GetRoomHistoryParams>,
@ -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<FetchEventParams>,

View file

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