From 97b8a732c44860a8558e675121bd61384ee32383 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:48:32 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20forum=20MCP=20tools=20=E2=80=94?= =?UTF-8?q?=20vote=5Fon=5Fpost,=20kinds=20filter,=20updated=20descriptions?= =?UTF-8?q?=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/sprout-acp/README.md | 28 +++++ crates/sprout-db/src/lib.rs | 4 +- crates/sprout-db/src/thread.rs | 12 ++ crates/sprout-mcp/src/lib.rs | 2 +- crates/sprout-mcp/src/server.rs | 143 +++++++++++++++++++++++- crates/sprout-mcp/src/toolsets.rs | 15 ++- crates/sprout-relay/src/api/messages.rs | 88 ++++++++++++++- 7 files changed, 273 insertions(+), 19 deletions(-) diff --git a/crates/sprout-acp/README.md b/crates/sprout-acp/README.md index 9d501bf60..9c7e4acb6 100644 --- a/crates/sprout-acp/README.md +++ b/crates/sprout-acp/README.md @@ -161,6 +161,34 @@ Heartbeat is designed for idle periods. Under sustained event load it will rarel Start with **N=2** for most deployments. Increase if queue depth grows under load. Each agent spawns its own MCP server subprocess, so resource usage scales approximately as N × (agent memory + MCP server memory). Maximum is 32. +## Forum Channels + +By default, the ACP harness subscribes to stream message kinds (9, 46010, 40007). To receive forum events, opt in with `--kinds` and disable the mention filter (forum posts don't @mention agents): + +**CLI flags:** +```bash +sprout-acp --kinds 9,46010,40007,45001,45002,45003 --no-mention-filter +``` + +**Or with `--subscribe all`:** +```bash +sprout-acp --subscribe all --kinds 9,46010,40007,45001,45002,45003 +``` + +**Per-channel config:** +```toml +[channel.CHANNEL_UUID] +kinds = [9, 46010, 40007, 45001, 45002, 45003] +require_mention = false +``` + +Forum event kinds: +- **45001** — Forum post (thread root) +- **45002** — Vote on a post or comment +- **45003** — Comment reply on a forum post + +> **Note:** Without `--no-mention-filter` (or `require_mention = false`), the default `subscribe=mentions` mode filters events that don't @mention the agent — forum posts will be invisible. + ## How It Works 1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth. diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index a75535076..10c9073be 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -551,8 +551,10 @@ impl Db { channel_id: Uuid, limit: u32, before: Option>, + kind_filter: Option<&[u32]>, ) -> Result> { - thread::get_channel_messages_top_level(&self.pool, channel_id, limit, before).await + thread::get_channel_messages_top_level(&self.pool, channel_id, limit, before, kind_filter) + .await } /// Decrement reply counts when a thread reply is deleted. diff --git a/crates/sprout-db/src/thread.rs b/crates/sprout-db/src/thread.rs index fc7d3111a..79fb06729 100644 --- a/crates/sprout-db/src/thread.rs +++ b/crates/sprout-db/src/thread.rs @@ -496,6 +496,7 @@ pub async fn get_channel_messages_top_level( channel_id: Uuid, limit: u32, before_cursor: Option>, + kind_filter: Option<&[u32]>, ) -> Result> { let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec(); @@ -527,6 +528,17 @@ pub async fn get_channel_messages_top_level( sql.push_str(" AND e.created_at < ?"); } + if let Some(kinds) = kind_filter { + if !kinds.is_empty() { + let list = kinds + .iter() + .map(|k| k.to_string()) + .collect::>() + .join(","); + sql.push_str(&format!(" AND e.kind IN ({list})")); + } + } + sql.push_str(" ORDER BY e.created_at DESC LIMIT ?"); let mut q = sqlx::query(&sql).bind(channel_id_bytes.as_slice()); diff --git a/crates/sprout-mcp/src/lib.rs b/crates/sprout-mcp/src/lib.rs index dc29fff9f..d0824a075 100644 --- a/crates/sprout-mcp/src/lib.rs +++ b/crates/sprout-mcp/src/lib.rs @@ -61,7 +61,7 @@ //! //! ## Available Tools //! -//! 41 tools total, organized into toolsets. Tools are organized into toolsets. Set +//! 42 tools total, organized into toolsets. Tools are organized into toolsets. Set //! `SPROUT_TOOLSETS` to control which are active (default: 25 core tools). //! //! ### Messaging (default toolset) diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index b412d806d..7f27b4927 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -120,12 +120,16 @@ pub struct GetMessagesParams { /// Maximum number of messages to return (default 50, max 200). #[serde(default)] pub limit: Option, - /// If true, fetch messages with thread metadata via REST instead of WebSocket. + /// Legacy parameter (thread summaries are now always included). Kept for backward compatibility. #[serde(default)] pub with_threads: Option, /// Unix timestamp cursor for pagination. Returns messages before this time. #[serde(default)] pub before: Option, + /// Comma-separated event kind numbers to filter by (e.g. "45001" for forum posts, + /// "45002" for votes). When omitted, all kinds are returned. + #[serde(default)] + pub kinds: Option, } /// Parameters for the `list_channels` tool. @@ -539,6 +543,27 @@ pub struct SendDiffMessageParams { pub parent_event_id: Option, } +/// Vote direction for forum posts. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum VoteDirection { + /// Upvote. + Up, + /// Downvote. + Down, +} + +/// Parameters for the `vote_on_post` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct VoteOnPostParams { + /// UUID of the forum channel. + pub channel_id: String, + /// 64-character hex event ID of the post or comment being voted on. + pub event_id: String, + /// Vote direction. + pub direction: VoteDirection, +} + // ── Diff utility functions ──────────────────────────────────────────────────── // Truncation notice appended when a diff is cut. This constant is used to @@ -657,7 +682,10 @@ impl SproutMcpServer { /// Send a message to a Sprout channel. #[tool( name = "send_message", - description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. Set `broadcast_to_channel` to also surface the reply in the main channel timeline." + description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. \ +Set `broadcast_to_channel` to also surface the reply in the main channel timeline. \ +For forum channels, set `kind` to 45001 (post) or 45003 (comment with `parent_event_id`). \ +Default kind is 9 (stream message)." )] pub async fn send_message(&self, Parameters(p): Parameters) -> String { if let Err(e) = validate_uuid(&p.channel_id) { @@ -923,7 +951,10 @@ impl SproutMcpServer { /// Get recent messages from a Sprout channel. #[tool( name = "get_messages", - description = "Get recent messages from a Sprout channel. Use `before` for pagination (Unix timestamp). Set `with_threads=true` to include thread metadata." + description = "Fetch recent top-level messages from a Sprout channel. Use `before` for pagination \ +(Unix timestamp). Use `kinds` to filter by event type (e.g. \"45001\" for forum posts, \ +\"45002\" for votes). Thread summaries are included automatically. Threaded replies \ +are not returned — use `get_thread` to fetch the full reply tree for a specific message." )] pub async fn get_messages(&self, Parameters(p): Parameters) -> String { if let Err(e) = validate_uuid(&p.channel_id) { @@ -933,8 +964,8 @@ impl SproutMcpServer { const MAX_HISTORY_LIMIT: u32 = 200; let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT); - // Use the REST endpoint so callers get the canonical history payload, - // including thread metadata when requested. + // Use the REST endpoint so callers get the canonical history payload. + // Note: with_threads is legacy — summaries are always included server-side. let with_threads = p.with_threads.unwrap_or(false); let mut query_parts: Vec = Vec::new(); if with_threads { @@ -944,6 +975,9 @@ impl SproutMcpServer { if let Some(before) = p.before { query_parts.push(format!("before={before}")); } + if let Some(ref kinds) = p.kinds { + query_parts.push(format!("kinds={}", percent_encode(kinds))); + } let path = format!( "/api/channels/{}/messages?{}", p.channel_id, @@ -1484,7 +1518,9 @@ impl SproutMcpServer { /// Get a message thread (replies to a message). #[tool( name = "get_thread", - description = "Get a message thread from a Sprout channel. Returns the root message and all nested replies." + description = "Fetch a full thread tree rooted at an event. Returns the root message and all nested \ +replies. Works for both stream message threads and forum post threads (kind:45001 root \ +with kind:45003 comments)." )] pub async fn get_thread(&self, Parameters(p): Parameters) -> String { if let Err(e) = validate_uuid(&p.channel_id) { @@ -1763,6 +1799,57 @@ impl SproutMcpServer { Err(e) => format!("Error: {e}"), } } + + /// Vote on a forum post or comment (kind:45002). + #[tool( + name = "vote_on_post", + description = "Vote on a forum post or comment. Creates a kind:45002 event. \ + Each vote is a separate event — vote deduplication is not yet enforced." + )] + pub async fn vote_on_post(&self, Parameters(p): Parameters) -> String { + if let Err(e) = validate_uuid(&p.channel_id) { + return format!("Error: {e}"); + } + if p.event_id.len() != 64 || !p.event_id.chars().all(|c| c.is_ascii_hexdigit()) { + return format!( + "Error: event_id must be a 64-character hex string (got {:?})", + p.event_id + ); + } + let content = match p.direction { + VoteDirection::Up => "+", + VoteDirection::Down => "-", + }; + + let sender_pubkey = self.client.keys().public_key().to_hex(); + let tags = match ( + Tag::parse(&["h", &p.channel_id]), + Tag::parse(&["p", &sender_pubkey]), + Tag::parse(&["e", &p.event_id]), + ) { + (Ok(h), Ok(p), Ok(e)) => vec![h, p, e], + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => { + return format!("Error: failed to build tags: {e}"); + } + }; + + let kind = Kind::from(sprout_core::kind::KIND_FORUM_VOTE as u16); + let event = match EventBuilder::new(kind, content, tags).sign_with_keys(self.client.keys()) + { + Ok(event) => event, + Err(e) => return format!("Error signing event: {e}"), + }; + + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), + Err(e) => format!("Error: {e}"), + } + } } #[tool_handler] @@ -2006,6 +2093,50 @@ mod tests { ] ); } + + // ── VoteDirection serde ─────────────────────────────────────────────────── + + #[test] + fn vote_direction_serializes_lowercase() { + assert_eq!(serde_json::to_string(&VoteDirection::Up).unwrap(), "\"up\""); + assert_eq!( + serde_json::to_string(&VoteDirection::Down).unwrap(), + "\"down\"" + ); + } + + #[test] + fn vote_direction_deserializes_lowercase() { + assert!(matches!( + serde_json::from_str::("\"up\"").unwrap(), + VoteDirection::Up + )); + assert!(matches!( + serde_json::from_str::("\"down\"").unwrap(), + VoteDirection::Down + )); + } + + #[test] + fn vote_direction_rejects_invalid() { + assert!(serde_json::from_str::("\"sideways\"").is_err()); + assert!(serde_json::from_str::("\"UP\"").is_err()); + assert!(serde_json::from_str::("\"\"").is_err()); + } + + #[test] + fn vote_on_post_params_round_trip() { + let params = VoteOnPostParams { + channel_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), + event_id: "a".repeat(64), + direction: VoteDirection::Up, + }; + let json = serde_json::to_string(¶ms).unwrap(); + let parsed: VoteOnPostParams = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.channel_id, params.channel_id); + assert_eq!(parsed.event_id, params.event_id); + assert!(matches!(parsed.direction, VoteDirection::Up)); + } } #[cfg(test)] diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs index 2704cfd85..bcea5aca5 100644 --- a/crates/sprout-mcp/src/toolsets.rs +++ b/crates/sprout-mcp/src/toolsets.rs @@ -25,6 +25,7 @@ //! | `canvas` | 2 | //! | `workflow_admin`| 5 | //! | `identity` | 1 | +//! | `forums` | 1 | use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; @@ -39,7 +40,7 @@ use std::sync::LazyLock; /// classification. `is_read = true` means the tool is safe to include under /// a `:ro` (read-only) mode restriction. /// -/// 41 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented. +/// 42 tools total. See [`DEFERRED_TOOLS`] for tools planned but not yet implemented. pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ // ── default ───────────────────────────────────────────────────────────── ("send_message", "default", false), @@ -88,6 +89,8 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ ("get_workflow_runs", "workflow_admin", true), // ── identity ────────────────────────────────────────────────────────────── ("set_channel_add_policy", "identity", false), + // ── forums ─────────────────────────────────────────────────────────────── + ("vote_on_post", "forums", false), // Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe ]; @@ -152,6 +155,7 @@ const KNOWN_TOOLSETS: &[&str] = &[ "media", "realtime", "identity", + "forums", ]; // --------------------------------------------------------------------------- @@ -366,8 +370,8 @@ mod tests { } #[test] - fn all_tools_count_is_41() { - assert_eq!(ALL_TOOLS.len(), 41); + fn all_tools_count_is_42() { + assert_eq!(ALL_TOOLS.len(), 42); } #[test] @@ -391,13 +395,14 @@ mod tests { #[test] fn all_toolsets_returns_correct_count() { - // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity + // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums // (media and realtime have no implemented tools yet) let defs = all_toolsets(); - assert_eq!(defs.len(), 6); + assert_eq!(defs.len(), 7); let names: Vec<_> = defs.iter().map(|d| d.name).collect(); assert!(names.contains(&"default")); assert!(names.contains(&"canvas")); + assert!(names.contains(&"forums")); } // ── Cross-check: ALL_TOOLS integrity ──────────────────────────────────── diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index f5ede7995..c41df08da 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -10,7 +10,7 @@ //! - `state.db.insert_thread_metadata(...)` → thread::insert_thread_metadata //! - `state.db.get_thread_replies(root_id, depth_limit, limit, cursor)` → thread::get_thread_replies //! - `state.db.get_thread_summary(event_id)` → thread::get_thread_summary -//! - `state.db.get_channel_messages_top_level(channel_id, limit, before)` → thread::get_channel_messages_top_level +//! - `state.db.get_channel_messages_top_level(channel_id, limit, before, kind_filter)` → thread::get_channel_messages_top_level //! - `state.db.get_thread_metadata_by_event(event_id)` → thread::get_thread_metadata_by_event //! - `state.db.get_event_by_id(id_bytes)` → event::get_event_by_id (already exists) //! - `state.db.insert_event(event, channel_id)` → event::insert_event (already exists) @@ -949,16 +949,19 @@ pub struct ListMessagesParams { /// Pagination cursor — Unix timestamp (seconds). Returns messages created /// strictly before this time. pub before: Option, - /// When `true`, include thread summaries for each message. + /// Legacy parameter (thread summaries are now always included). Kept for backward compatibility. #[serde(default)] pub with_threads: bool, + /// Comma-separated event kind numbers to filter by (e.g. "45001" or "9,45001"). + #[serde(default)] + pub kinds: Option, } /// List top-level messages in a channel (newest first). /// -/// Returns root messages and broadcast replies. Thread replies are excluded -/// unless `with_threads=true`, in which case each message includes a -/// `thread_summary` with reply counts and participant pubkeys. +/// Returns root messages and broadcast replies. Thread summaries (reply counts, +/// participant pubkeys) are always included. Thread replies themselves are excluded — +/// use `get_thread` to fetch the full reply tree for a specific message. pub async fn list_messages( State(state): State>, headers: HeaderMap, @@ -982,9 +985,22 @@ pub async fn list_messages( .before .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)); + let kind_filter: Option> = params + .kinds + .as_deref() + .map(|s| { + s.split(',') + .map(|k| k.trim().parse::()) + .collect::, _>>() + }) + .transpose() + .map_err(|_| { + api_error(StatusCode::BAD_REQUEST, "Invalid 'kinds' parameter — expected comma-separated integers (e.g. '45001' or '9,45001')") + })?; + let mut messages = state .db - .get_channel_messages_top_level(channel_id, limit, before_cursor) + .get_channel_messages_top_level(channel_id, limit, before_cursor, kind_filter.as_deref()) .await .map_err(|e| internal_error(&format!("db error: {e}")))?; @@ -1517,4 +1533,64 @@ mod tests { ]; assert!(validate_imeta_tags(&[tag], BASE).is_ok()); } + + // ── kinds filter parsing ──────────────────────────────────────────────── + + /// Helper: simulate the kinds-parsing logic from `list_messages`. + fn parse_kinds(input: Option<&str>) -> Result>, ()> { + input + .map(|s| { + s.split(',') + .map(|k| k.trim().parse::()) + .collect::, _>>() + }) + .transpose() + .map_err(|_| ()) + } + + #[test] + fn kinds_none_returns_none() { + assert_eq!(parse_kinds(None), Ok(None)); + } + + #[test] + fn kinds_single_value() { + assert_eq!(parse_kinds(Some("45001")), Ok(Some(vec![45001]))); + } + + #[test] + fn kinds_multiple_values() { + assert_eq!( + parse_kinds(Some("9,45001,45002")), + Ok(Some(vec![9, 45001, 45002])) + ); + } + + #[test] + fn kinds_with_whitespace() { + assert_eq!( + parse_kinds(Some("45001 , 45002")), + Ok(Some(vec![45001, 45002])) + ); + } + + #[test] + fn kinds_empty_string_is_error() { + assert!(parse_kinds(Some("")).is_err()); + } + + #[test] + fn kinds_non_numeric_is_error() { + assert!(parse_kinds(Some("abc")).is_err()); + } + + #[test] + fn kinds_mixed_valid_invalid_is_error() { + assert!(parse_kinds(Some("45001,abc")).is_err()); + } + + #[test] + fn kinds_negative_is_error() { + assert!(parse_kinds(Some("-1")).is_err()); + } }