feat: add forum MCP tools — vote_on_post, kinds filter, updated descriptions (#112)

This commit is contained in:
tlongwell-block
2026-03-18 21:48:32 -04:00
committed by GitHub
parent 5151366d29
commit 97b8a732c4
7 changed files with 273 additions and 19 deletions
+28
View File
@@ -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.
+3 -1
View File
@@ -551,8 +551,10 @@ impl Db {
channel_id: Uuid,
limit: u32,
before: Option<DateTime<Utc>>,
kind_filter: Option<&[u32]>,
) -> Result<Vec<thread::TopLevelMessage>> {
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.
+12
View File
@@ -496,6 +496,7 @@ pub async fn get_channel_messages_top_level(
channel_id: Uuid,
limit: u32,
before_cursor: Option<DateTime<Utc>>,
kind_filter: Option<&[u32]>,
) -> Result<Vec<TopLevelMessage>> {
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::<Vec<_>>()
.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());
+1 -1
View File
@@ -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)
+137 -6
View File
@@ -120,12 +120,16 @@ pub struct GetMessagesParams {
/// Maximum number of messages to return (default 50, max 200).
#[serde(default)]
pub limit: Option<u32>,
/// 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<bool>,
/// Unix timestamp cursor for pagination. Returns messages before this time.
#[serde(default)]
pub before: Option<i64>,
/// 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<String>,
}
/// Parameters for the `list_channels` tool.
@@ -539,6 +543,27 @@ pub struct SendDiffMessageParams {
pub parent_event_id: Option<String>,
}
/// 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<SendMessageParams>) -> 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<GetMessagesParams>) -> 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<String> = 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<GetThreadParams>) -> 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<VoteOnPostParams>) -> 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::<VoteDirection>("\"up\"").unwrap(),
VoteDirection::Up
));
assert!(matches!(
serde_json::from_str::<VoteDirection>("\"down\"").unwrap(),
VoteDirection::Down
));
}
#[test]
fn vote_direction_rejects_invalid() {
assert!(serde_json::from_str::<VoteDirection>("\"sideways\"").is_err());
assert!(serde_json::from_str::<VoteDirection>("\"UP\"").is_err());
assert!(serde_json::from_str::<VoteDirection>("\"\"").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(&params).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)]
+10 -5
View File
@@ -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 ────────────────────────────────────
+82 -6
View File
@@ -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<i64>,
/// 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<String>,
}
/// 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<Arc<AppState>>,
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<Vec<u32>> = params
.kinds
.as_deref()
.map(|s| {
s.split(',')
.map(|k| k.trim().parse::<u32>())
.collect::<Result<Vec<_>, _>>()
})
.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<Option<Vec<u32>>, ()> {
input
.map(|s| {
s.split(',')
.map(|k| k.trim().parse::<u32>())
.collect::<Result<Vec<_>, _>>()
})
.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());
}
}