diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index 483255699..26531f30c 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -743,11 +743,11 @@ fn default_heartbeat_prompt() -> String { You have been awakened for a routine heartbeat. You have NO incoming messages or\n\ active channel context for this turn.\n\n\ Your tasks:\n\ - 1. Call `get_feed_actions()` to check for pending workflow approvals or\n\ + 1. Call `get_feed(types='needs_action')` to check for pending workflow approvals or\n\ high-priority requests addressed to you.\n\ - 2. Call `get_feed_mentions()` to check for unanswered @mentions.\n\ + 2. Call `get_feed(types='mentions')` to check for unanswered @mentions.\n\ 3. If you find actionable items, address them using the appropriate tools\n\ - (e.g., `approve_workflow_step`, `send_message`, `send_reply`).\n\ + (e.g., `approve_step`, `send_message`, `send_message(parent_event_id=...)`).\n\ 4. If there are no pending actions or mentions, end your turn immediately.\n\n\ Do not call `list_channels()` or `search()` unless you have a specific reason.\n\ Do not invent work — only act on items surfaced by the feed tools." diff --git a/crates/sprout-mcp/src/lib.rs b/crates/sprout-mcp/src/lib.rs index 2fc09292a..dc29fff9f 100644 --- a/crates/sprout-mcp/src/lib.rs +++ b/crates/sprout-mcp/src/lib.rs @@ -61,34 +61,39 @@ //! //! ## Available Tools //! -//! ### Messaging -//! - **`send_message`** — Post a message to a channel (Nostr kind 9 by default). -//! - **`get_channel_history`** — Fetch recent messages from a channel (default 50, max 200). +//! 41 tools total, organized into toolsets. Tools are organized into toolsets. Set +//! `SPROUT_TOOLSETS` to control which are active (default: 25 core tools). //! -//! ### Channels -//! - **`list_channels`** — List channels accessible to this agent, optionally filtered by -//! visibility (`open` / `private`). -//! - **`create_channel`** — Create a new channel with a given name, type, and visibility. +//! ### Messaging (default toolset) +//! - **`send_message`** — Post a message to a channel. +//! - **`send_diff_message`** — Post a diff-formatted message. +//! - **`edit_message`** — Edit an existing message. +//! - **`delete_message`** — Delete a message. +//! - **`get_messages`** — Fetch recent messages from a channel (default 50, max 200). +//! - **`get_thread`** — Fetch replies in a message thread. +//! - **`search`** — Full-text search across channels. +//! - **`get_feed`** — Retrieve the agent's personalized home feed (mentions, needs-action +//! items, channel activity). Replaces the former `get_feed_mentions` / `get_feed_actions`. +//! - **`add_reaction`** / **`remove_reaction`** / **`get_reactions`** — Emoji reactions. //! -//! ### Canvas +//! ### Channels (default toolset) +//! - **`list_channels`** / **`get_channel`** — List or inspect channels. +//! - **`join_channel`** / **`leave_channel`** — Membership management. +//! - **`update_channel`** / **`set_channel_topic`** / **`set_channel_purpose`** — Metadata. +//! - **`open_dm`** — Open a direct-message channel. +//! +//! ### Channel Admin (`channel_admin` toolset) +//! - **`create_channel`** / **`archive_channel`** / **`unarchive_channel`** +//! - **`add_channel_member`** / **`remove_channel_member`** / **`list_channel_members`** +//! +//! ### Canvas (`canvas` toolset) //! - **`get_canvas`** — Retrieve the shared canvas document for a channel. //! - **`set_canvas`** — Write or replace the canvas document for a channel. //! //! ### Workflows -//! - **`list_workflows`** — List workflows defined in a channel. -//! - **`create_workflow`** — Create a workflow from a YAML definition. -//! - **`update_workflow`** — Replace an existing workflow's YAML definition. -//! - **`delete_workflow`** — Delete a workflow by ID. -//! - **`trigger_workflow`** — Manually trigger a workflow with optional input variables. -//! - **`get_workflow_runs`** — Fetch execution history for a workflow (default 20, max 100). -//! - **`approve_workflow_step`** — Approve or deny a pending human-approval step. -//! -//! ### Feed -//! - **`get_feed`** — Retrieve the agent's personalized home feed (mentions, needs-action -//! items, channel activity, agent activity). Max 50 items per category. -//! - **`get_feed_mentions`** — Fetch only `@mentions` for this agent. Max 50 items. -//! - **`get_feed_actions`** — Fetch items requiring action (approval requests, reminders). -//! Max 50 items. +//! - **`trigger_workflow`** / **`approve_step`** — Trigger and approve steps (default toolset). +//! - **`list_workflows`** / **`create_workflow`** / **`update_workflow`** / +//! **`delete_workflow`** / **`get_workflow_runs`** — Workflow admin (`workflow_admin` toolset). //! //! ## Example Configuration (Claude Desktop) //! @@ -118,3 +123,5 @@ pub mod relay_client; /// MCP tool implementations backed by the relay client. pub mod server; +/// Toolset definitions and configuration for organizing MCP tools. +pub mod toolsets; diff --git a/crates/sprout-mcp/src/main.rs b/crates/sprout-mcp/src/main.rs index 2f477cb65..77bad25a5 100644 --- a/crates/sprout-mcp/src/main.rs +++ b/crates/sprout-mcp/src/main.rs @@ -5,6 +5,7 @@ use tracing_subscriber::EnvFilter; use sprout_mcp::relay_client::RelayClient; use sprout_mcp::server::SproutMcpServer; +use sprout_mcp::toolsets::ToolsetConfig; #[tokio::main] async fn main() -> Result<()> { @@ -33,11 +34,15 @@ async fn main() -> Result<()> { } }; + let toolset_config = ToolsetConfig::from_env(); + eprintln!("sprout-mcp: toolsets: {:?}", toolset_config); + eprintln!("sprout-mcp: connecting to relay at {relay_url}..."); let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref()).await?; eprintln!("sprout-mcp: connected and authenticated."); - let server = SproutMcpServer::new(client); + let tools_to_remove = toolset_config.tools_to_remove(); + let server = SproutMcpServer::new(client, Some(tools_to_remove)); let service = server.serve(stdio()).await?; service.waiting().await?; diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 3d831b566..102364406 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -58,14 +58,20 @@ pub struct SendMessageParams { /// Optional parent event ID. If provided, sends a reply via REST instead of WebSocket. #[serde(default)] pub parent_event_id: Option, + /// If true and parent_event_id is set, surface the reply in the main channel timeline. + #[serde(default)] + pub broadcast_to_channel: Option, + /// Pubkeys to @mention in the message. + #[serde(default)] + pub mention_pubkeys: Option>, } fn default_kind() -> Option { Some(sprout_core::kind::KIND_STREAM_MESSAGE as u16) } -/// Parameters for the `get_channel_history` tool. +/// Parameters for the `get_messages` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct GetChannelHistoryParams { +pub struct GetMessagesParams { /// UUID of the channel to fetch history from. pub channel_id: String, /// Maximum number of messages to return (default 50, max 200). @@ -74,6 +80,9 @@ pub struct GetChannelHistoryParams { /// If true, fetch messages with thread metadata via REST instead of WebSocket. #[serde(default)] pub with_threads: Option, + /// Unix timestamp cursor for pagination. Returns messages before this time. + #[serde(default)] + pub before: Option, } /// Parameters for the `list_channels` tool. @@ -180,9 +189,9 @@ pub struct GetWorkflowRunsParams { pub limit: Option, } -/// Parameters for the `approve_workflow_step` tool. +/// Parameters for the `approve_step` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ApproveWorkflowStepParams { +pub struct ApproveStepParams { /// Opaque approval token from the kind:46010 event. pub approval_token: String, /// true = approve, false = deny. @@ -294,24 +303,6 @@ pub struct UnarchiveChannelParams { // ── Thread tool parameter structs ───────────────────────────────────────────── -/// Parameters for the `send_reply` tool. -#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct SendReplyParams { - /// UUID of the channel containing the parent message. - pub channel_id: String, - /// Event ID of the message being replied to. - pub parent_event_id: String, - /// Reply message body text. - pub content: String, - /// If true, the reply is also broadcast to the main channel timeline. - #[serde(default)] - pub broadcast_to_channel: Option, - /// Hex-encoded pubkeys of users/agents mentioned in this reply. - /// Required for @mention notifications to reach mention-filtered subscribers. - #[serde(default)] - pub mention_pubkeys: Option>, -} - /// Parameters for the `get_thread` tool. #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct GetThreadParams { @@ -391,19 +382,12 @@ pub struct SetProfileParams { pub nip05_handle: Option, } -/// Parameters for the `get_user_profile` tool. -#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct GetUserProfileParams { - /// Hex-encoded pubkey to look up. Omit to get your own profile. - #[serde(default)] - pub pubkey: Option, -} - -/// Parameters for the `get_users_batch` tool. -#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct GetUsersBatchParams { - /// List of hex-encoded pubkeys to look up (max 200). - pub pubkeys: Vec, +/// Parameters for the `get_users` tool. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetUsersParams { + /// Pubkey(s) to look up. Omit for your own profile. Provide one hex pubkey + /// for a single user, or multiple for batch lookup (max 200). + pub pubkeys: Option>, } /// Parameters for the `search` tool. @@ -456,28 +440,22 @@ pub struct GetFeedParams { pub types: Option, } -/// Parameters for the `get_feed_mentions` tool. -#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct GetFeedMentionsParams { - /// Only return mentions newer than this Unix timestamp. - /// Defaults to now - 7 days if omitted. - #[serde(default)] - pub since: Option, - /// Maximum items to return. Default 50, max 50. - #[serde(default)] - pub limit: Option, +/// Parameters for the `edit_message` tool. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct EditMessageParams { + /// Channel ID (UUID) containing the message to edit. + pub channel_id: String, + /// Event ID (64-char hex) of the message to edit. + pub event_id: String, + /// New content for the message. + pub content: String, } -/// Parameters for the `get_feed_actions` tool. -#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct GetFeedActionsParams { - /// Only return action items newer than this Unix timestamp. - /// Defaults to now - 7 days if omitted. - #[serde(default)] - pub since: Option, - /// Maximum items to return. Default 50, max 50. - #[serde(default)] - pub limit: Option, +/// Parameters for the `delete_message` tool. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeleteMessageParams { + /// Event ID (64-char hex) of the message to delete. + pub event_id: String, } /// Parameters for the `send_diff_message` tool. @@ -614,17 +592,29 @@ pub struct SproutMcpServer { #[tool_router] impl SproutMcpServer { /// Create a new [`SproutMcpServer`] backed by the given relay client. - pub fn new(client: RelayClient) -> Self { + /// + /// Pass `tools_to_remove` to filter out tools by name (e.g. from toolset config). + pub fn new( + client: RelayClient, + tools_to_remove: Option>, + ) -> Self { + let mut tool_router = Self::tool_router(); + if let Some(ref remove) = tools_to_remove { + for name in remove { + tool_router.remove_route(name); + } + } + Self { client, - tool_router: Self::tool_router(), + tool_router, } } /// Send a message to a Sprout channel. #[tool( name = "send_message", - description = "Send a message to a Sprout channel. Optionally supply parent_event_id to send as a threaded reply via REST." + 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." )] pub async fn send_message(&self, Parameters(p): Parameters) -> String { if let Err(e) = validate_uuid(&p.channel_id) { @@ -639,6 +629,26 @@ impl SproutMcpServer { ); } + // Validate reply fields when present. + if let Some(ref parent_id) = p.parent_event_id { + if parent_id.len() != 64 || !parent_id.chars().all(|c| c.is_ascii_hexdigit()) { + return format!( + "Error: parent_event_id must be a 64-character hex string (got {:?})", + parent_id + ); + } + } + if let Some(ref mentions) = p.mention_pubkeys { + for pk in mentions { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + return format!( + "Error: mention_pubkeys entry must be a 64-character hex string (got {:?})", + pk + ); + } + } + } + // Use a user-signed WebSocket event for top-level messages so downstream // clients see the agent pubkey directly rather than the relay pubkey. // Threaded replies still go through REST because that path handles the @@ -672,6 +682,7 @@ impl SproutMcpServer { let mut body = serde_json::json!({ "content": p.content, + "broadcast_to_channel": p.broadcast_to_channel.unwrap_or(false), }); if let Some(ref parent_id) = p.parent_event_id { body["parent_event_id"] = serde_json::Value::String(parent_id.clone()); @@ -679,6 +690,9 @@ impl SproutMcpServer { if let Some(kind) = p.kind { body["kind"] = serde_json::json!(kind); } + if let Some(ref mentions) = p.mention_pubkeys { + body["mention_pubkeys"] = serde_json::json!(mentions); + } match self .client .post(&format!("/api/channels/{}/messages", p.channel_id), &body) @@ -786,15 +800,84 @@ impl SproutMcpServer { } } + /// Edit a message you previously sent. + #[tool( + name = "edit_message", + description = "Edit a message you previously sent. Creates an edit event (kind 40003) referencing the original." + )] + pub async fn edit_message(&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 + ); + } + if p.content.len() > MAX_CONTENT_BYTES { + return format!( + "Error: content exceeds maximum size of {} bytes (got {})", + MAX_CONTENT_BYTES, + p.content.len() + ); + } + + let kind = Kind::from(sprout_core::kind::KIND_STREAM_MESSAGE_EDIT as u16); + let tags = match ( + Tag::parse(&["h", &p.channel_id]), + Tag::parse(&["e", &p.event_id]), + ) { + (Ok(h_tag), Ok(e_tag)) => vec![h_tag, e_tag], + (Err(e), _) | (_, Err(e)) => return format!("Error: failed to build tags: {e}"), + }; + + let event = + match EventBuilder::new(kind, p.content, tags).sign_with_keys(self.client.keys()) { + Ok(event) => event, + Err(e) => return format!("Error: failed to sign edit 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}"), + } + } + + /// Delete a message. + #[tool( + name = "delete_message", + description = "Delete a message. You must be the message author or a channel owner/admin." + )] + pub async fn delete_message(&self, Parameters(p): Parameters) -> String { + 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 encoded = percent_encode(&p.event_id); + match self + .client + .delete(&format!("/api/messages/{}", encoded)) + .await + { + Ok(_) => "Message deleted.".to_string(), + Err(e) => format!("Error: {e}"), + } + } + /// Get recent messages from a Sprout channel. #[tool( - name = "get_channel_history", - description = "Get recent messages from a Sprout channel. Set with_threads=true to include thread metadata via REST." + 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." )] - pub async fn get_channel_history( - &self, - Parameters(p): Parameters, - ) -> String { + pub async fn get_messages(&self, Parameters(p): Parameters) -> String { if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } @@ -805,14 +888,19 @@ impl SproutMcpServer { // Use the REST endpoint so callers get the canonical history payload, // including thread metadata when requested. let with_threads = p.with_threads.unwrap_or(false); - let path = if with_threads { - format!( - "/api/channels/{}/messages?with_threads=true&limit={}", - p.channel_id, limit - ) - } else { - format!("/api/channels/{}/messages?limit={}", p.channel_id, limit) - }; + let mut query_parts: Vec = Vec::new(); + if with_threads { + query_parts.push("with_threads=true".to_string()); + } + query_parts.push(format!("limit={limit}")); + if let Some(before) = p.before { + query_parts.push(format!("before={before}")); + } + let path = format!( + "/api/channels/{}/messages?{}", + p.channel_id, + query_parts.join("&") + ); match self.client.get(&path).await { Ok(body) => body, Err(e) => format!("Error: {e}"), @@ -1038,13 +1126,10 @@ impl SproutMcpServer { /// Approve or deny a pending workflow approval step. #[tool( - name = "approve_workflow_step", + name = "approve_step", description = "Approve or deny a pending workflow approval step" )] - pub async fn approve_workflow_step( - &self, - Parameters(p): Parameters, - ) -> String { + pub async fn approve_step(&self, Parameters(p): Parameters) -> String { if uuid::Uuid::parse_str(&p.approval_token).is_err() { return format!( "Error: approval_token '{}' is not a valid UUID", @@ -1097,59 +1182,6 @@ impl SproutMcpServer { } } - /// Get only @mentions for this agent from the Sprout relay. - #[tool( - name = "get_feed_mentions", - description = "Get only @mentions for this agent from the Sprout relay. \ - Returns events where the agent's pubkey appears in a p-tag. \ - Equivalent to the @Mentions tab on the Home feed." - )] - pub async fn get_feed_mentions( - &self, - Parameters(p): Parameters, - ) -> String { - const MAX_FEED_LIMIT: u32 = 50; - let mut url = format!("{}/api/feed?types=mentions", self.client.relay_http_url()); - if let Some(since) = p.since { - url = format!("{url}&since={since}"); - } - if let Some(limit) = p.limit { - url = format!("{url}&limit={}", limit.min(MAX_FEED_LIMIT)); - } - match self.client.get_api(&url).await { - Ok(body) => body, - Err(e) => format!("Error fetching mentions: {e}"), - } - } - - /// Get items that require action from this agent. - #[tool( - name = "get_feed_actions", - description = "Get items that require action from this agent: approval requests (kind 46010) \ - and reminders (kind 40007) addressed to the agent's pubkey. \ - Equivalent to the 'Needs Action' section on the Home feed." - )] - pub async fn get_feed_actions( - &self, - Parameters(p): Parameters, - ) -> String { - const MAX_FEED_LIMIT: u32 = 50; - let mut url = format!( - "{}/api/feed?types=needs_action", - self.client.relay_http_url() - ); - if let Some(since) = p.since { - url = format!("{url}&since={since}"); - } - if let Some(limit) = p.limit { - url = format!("{url}&limit={}", limit.min(MAX_FEED_LIMIT)); - } - match self.client.get_api(&url).await { - Ok(body) => body, - Err(e) => format!("Error fetching action items: {e}"), - } - } - // ── Membership tools ────────────────────────────────────────────────────── /// Add a member to a channel. @@ -1401,43 +1433,6 @@ impl SproutMcpServer { // ── Thread tools ────────────────────────────────────────────────────────── - /// Send a reply to a message in a thread. - #[tool( - name = "send_reply", - description = "Send a reply to a message in a Sprout channel thread. \ - Optionally set broadcast_to_channel=true to also surface the reply in the main channel timeline." - )] - pub async fn send_reply(&self, Parameters(p): Parameters) -> String { - if let Err(e) = validate_uuid(&p.channel_id) { - return format!("Error: {e}"); - } - - if p.content.len() > MAX_CONTENT_BYTES { - return format!( - "Error: content exceeds maximum size of {} bytes (got {})", - MAX_CONTENT_BYTES, - p.content.len() - ); - } - - let mut body = serde_json::json!({ - "content": p.content, - "parent_event_id": p.parent_event_id, - "broadcast_to_channel": p.broadcast_to_channel.unwrap_or(false), - }); - if let Some(ref mentions) = p.mention_pubkeys { - body["mention_pubkeys"] = serde_json::json!(mentions); - } - match self - .client - .post(&format!("/api/channels/{}/messages", p.channel_id), &body) - .await - { - Ok(b) => b, - Err(e) => format!("Error: {e}"), - } - } - /// Get a message thread (replies to a message). #[tool( name = "get_thread", @@ -1615,35 +1610,43 @@ impl SproutMcpServer { } } - /// Read a user's profile by pubkey. + /// Get user profile(s) by pubkey. #[tool( - name = "get_user_profile", - description = "Get a user's profile by pubkey. Omit pubkey to get your own profile. Returns display name, avatar URL, about text, and NIP-05 handle." + name = "get_users", + description = "Get user profile(s). Omit pubkeys for your own profile, provide one for a specific user, or provide multiple for batch lookup (max 200)." )] - pub async fn get_user_profile( - &self, - Parameters(p): Parameters, - ) -> String { - let path = match p.pubkey { - None => "/api/users/me/profile".to_string(), - Some(pk) => format!("/api/users/{}/profile", pk), - }; - match self.client.get(&path).await { - Ok(body) => body, - Err(e) => format!("Error fetching profile: {e}"), + pub async fn get_users(&self, Parameters(p): Parameters) -> String { + let pubkeys = p.pubkeys.unwrap_or_default(); + if pubkeys.len() > 200 { + return "Error: max 200 pubkeys for batch lookup".to_string(); } - } - - /// Resolve display names for multiple pubkeys. - #[tool( - name = "get_users_batch", - description = "Resolve display names and NIP-05 handles for multiple pubkeys at once. Returns a map of pubkey to profile info, plus a list of unknown pubkeys. Useful for identifying message senders in bulk." - )] - pub async fn get_users_batch(&self, Parameters(p): Parameters) -> String { - let body = serde_json::json!({ "pubkeys": p.pubkeys }); - match self.client.post("/api/users/batch", &body).await { - Ok(resp) => resp, - Err(e) => format!("Error fetching profiles: {e}"), + for pk in &pubkeys { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + return format!( + "Error: pubkey must be a 64-character hex string (got {:?})", + pk + ); + } + } + match pubkeys.len() { + 0 => match self.client.get("/api/users/me/profile").await { + Ok(body) => body, + Err(e) => format!("Error fetching profile: {e}"), + }, + 1 => { + let path = format!("/api/users/{}/profile", percent_encode(&pubkeys[0])); + match self.client.get(&path).await { + Ok(body) => body, + Err(e) => format!("Error fetching profile: {e}"), + } + } + _ => { + let body = serde_json::json!({ "pubkeys": pubkeys }); + match self.client.post("/api/users/batch", &body).await { + Ok(resp) => resp, + Err(e) => format!("Error fetching profiles: {e}"), + } + } } } diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs new file mode 100644 index 000000000..2704cfd85 --- /dev/null +++ b/crates/sprout-mcp/src/toolsets.rs @@ -0,0 +1,436 @@ +//! # Toolset System +//! +//! Controls which MCP tools are exposed based on the `SPROUT_TOOLSETS` environment +//! variable. +//! +//! ## Syntax +//! +//! ```text +//! SPROUT_TOOLSETS="default,channel_admin:ro,canvas" +//! ``` +//! +//! Comma-separated list of toolset names with optional `:ro` / `:rw` suffix. +//! Special keywords: `default`, `all`, `none`. +//! +//! Later entries override earlier ones, so `all:ro,default:rw` gives read-only +//! access everywhere except the default toolset which gets full write access. +//! +//! ## Toolsets +//! +//! | Name | Tools | +//! |-----------------|-------| +//! | `default` | 25 | +//! | `channel_admin` | 6 | +//! | `dms` | 2 | +//! | `canvas` | 2 | +//! | `workflow_admin`| 5 | +//! | `identity` | 1 | + +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +// --------------------------------------------------------------------------- +// Static data +// --------------------------------------------------------------------------- + +/// `(tool_name, toolset_name, is_read)` +/// +/// Single source of truth for every tool's toolset membership and read/write +/// 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. +pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ + // ── default ───────────────────────────────────────────────────────────── + ("send_message", "default", false), + ("send_diff_message", "default", false), + ("edit_message", "default", false), + ("delete_message", "default", false), + ("get_messages", "default", true), + ("get_thread", "default", true), + ("search", "default", true), + ("get_feed", "default", true), + ("add_reaction", "default", false), + ("remove_reaction", "default", false), + ("get_reactions", "default", true), + ("list_channels", "default", true), + ("get_channel", "default", true), + ("join_channel", "default", false), + ("leave_channel", "default", false), + ("update_channel", "default", false), + ("set_channel_topic", "default", false), + ("set_channel_purpose", "default", false), + ("open_dm", "default", false), + ("get_users", "default", true), + ("set_profile", "default", false), + ("get_presence", "default", true), + ("set_presence", "default", false), + ("trigger_workflow", "default", false), + ("approve_step", "default", false), + // ── channel_admin ──────────────────────────────────────────────────────── + ("create_channel", "channel_admin", false), + ("archive_channel", "channel_admin", false), + ("unarchive_channel", "channel_admin", false), + ("add_channel_member", "channel_admin", false), + ("remove_channel_member", "channel_admin", false), + ("list_channel_members", "channel_admin", true), + // ── dms ────────────────────────────────────────────────────────────────── + ("add_dm_member", "dms", false), + ("list_dms", "dms", true), + // ── canvas ─────────────────────────────────────────────────────────────── + ("get_canvas", "canvas", true), + ("set_canvas", "canvas", false), + // ── workflow_admin ──────────────────────────────────────────────────────── + ("list_workflows", "workflow_admin", true), + ("create_workflow", "workflow_admin", false), + ("update_workflow", "workflow_admin", false), + ("delete_workflow", "workflow_admin", false), + ("get_workflow_runs", "workflow_admin", true), + // ── identity ────────────────────────────────────────────────────────────── + ("set_channel_add_policy", "identity", false), + // Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe +]; + +/// Tools planned but not yet implemented. These will be added to ALL_TOOLS +/// when their #[tool] handlers are created in server.rs. +pub const DEFERRED_TOOLS: &[(&str, &str, bool)] = &[ + ("upload_file", "media", false), + ("subscribe", "realtime", true), + ("unsubscribe", "realtime", false), +]; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Access mode for a toolset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// All tools in the toolset (read + write). + ReadWrite, + /// Read-only tools only. + ReadOnly, +} + +/// Metadata about a toolset. +#[derive(Debug, Clone)] +pub struct ToolsetDef { + /// Toolset name, e.g. `"channel_admin"`. + pub name: &'static str, + /// All tools belonging to this toolset. + pub tools: &'static [ToolDef], +} + +/// Metadata about a single tool. +#[derive(Debug, Clone, Copy)] +pub struct ToolDef { + /// Tool name, e.g. `"get_messages"`. + pub name: &'static str, + /// Whether the tool is safe under `:ro` mode. + pub is_read: bool, +} + +/// Parsed toolset configuration. +/// +/// Construct via [`ToolsetConfig::parse`] or [`ToolsetConfig::from_env`]. +#[derive(Debug, Clone)] +pub struct ToolsetConfig { + /// `toolset_name → Mode`. Only explicitly enabled toolsets appear here. + enabled: HashMap<&'static str, Mode>, +} + +// --------------------------------------------------------------------------- +// Known toolset names (compile-time set for validation) +// --------------------------------------------------------------------------- + +const KNOWN_TOOLSETS: &[&str] = &[ + "default", + "channel_admin", + "dms", + "canvas", + "workflow_admin", + "media", + "realtime", + "identity", +]; + +// --------------------------------------------------------------------------- +// Lazy static toolset definitions (built from ALL_TOOLS) +// --------------------------------------------------------------------------- + +static TOOLSET_DEFS: LazyLock> = LazyLock::new(|| { + let mut map: std::collections::BTreeMap<&'static str, Vec> = + std::collections::BTreeMap::new(); + for &(tool, ts, is_read) in ALL_TOOLS { + map.entry(ts).or_default().push(ToolDef { + name: tool, + is_read, + }); + } + map.into_iter() + .map(|(name, tools)| ToolsetDef { + name, + tools: Box::leak(tools.into_boxed_slice()), + }) + .collect() +}); + +/// Returns all toolset definitions, built once from [`ALL_TOOLS`]. +pub fn all_toolsets() -> &'static [ToolsetDef] { + &TOOLSET_DEFS +} + +/// Returns the tools belonging to `name`, or `None` if the toolset is unknown. +pub fn tools_in_toolset(name: &str) -> Option> { + let tools: Vec = ALL_TOOLS + .iter() + .filter(|&&(_, ts, _)| ts == name) + .map(|&(tool, _, is_read)| ToolDef { + name: tool, + is_read, + }) + .collect(); + if tools.is_empty() { + None + } else { + Some(tools) + } +} + +// --------------------------------------------------------------------------- +// ToolsetConfig implementation +// --------------------------------------------------------------------------- + +impl ToolsetConfig { + /// Parse a comma-separated toolset string. + /// + /// # Keywords + /// - `default` — enables the `default` toolset + /// - `all` — enables every toolset + /// - `none` — clears all enabled toolsets + /// + /// # Mode suffixes + /// - `:ro` — read-only (only tools with `is_read = true`) + /// - `:rw` — read-write (default) + /// + /// Later entries override earlier ones. + pub fn parse(input: &str) -> Self { + let mut enabled: HashMap<&'static str, Mode> = HashMap::new(); + + for token in input.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let (name, mode) = if let Some(n) = token.strip_suffix(":ro") { + (n, Mode::ReadOnly) + } else if let Some(n) = token.strip_suffix(":rw") { + (n, Mode::ReadWrite) + } else { + (token, Mode::ReadWrite) + }; + + match name { + "none" => { + enabled.clear(); + } + "all" => { + for &ts in KNOWN_TOOLSETS { + enabled.insert(ts, mode); + } + } + "default" => { + enabled.insert("default", mode); + } + other => { + // Intern to &'static str if known; warn and skip if not. + if let Some(&known) = KNOWN_TOOLSETS.iter().find(|&&k| k == other) { + enabled.insert(known, mode); + } else { + eprintln!("sprout-mcp: unknown toolset {:?} — skipping", other); + } + } + } + } + + Self { enabled } + } + + /// Parse from `SPROUT_TOOLSETS`, falling back to `"default"`. + /// + /// An empty string (e.g. `SPROUT_TOOLSETS=""`) is treated the same as unset. + pub fn from_env() -> Self { + let raw = std::env::var("SPROUT_TOOLSETS") + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "default".to_string()); + Self::parse(&raw) + } + + /// Returns the set of tool names that should be **removed** from the router. + /// + /// Callers pass each name to `ToolRouter::remove_route()`. + pub fn tools_to_remove(&self) -> HashSet<&'static str> { + ALL_TOOLS + .iter() + .filter(|&&(_tool, ts, is_read)| { + match self.enabled.get(ts) { + None => true, // toolset not enabled → remove + Some(Mode::ReadWrite) => false, // fully enabled → keep + Some(Mode::ReadOnly) => !is_read, // ro → remove write tools + } + }) + .map(|&(tool, _, _)| tool) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn enabled_tools(input: &str) -> HashSet<&'static str> { + let cfg = ToolsetConfig::parse(input); + let remove = cfg.tools_to_remove(); + ALL_TOOLS + .iter() + .map(|&(t, _, _)| t) + .filter(|t| !remove.contains(t)) + .collect() + } + + #[test] + fn default_includes_25_tools() { + let tools = enabled_tools("default"); + assert_eq!(tools.len(), 25); + assert!(tools.contains("send_message")); + assert!(tools.contains("approve_step")); + assert!(!tools.contains("create_channel")); + } + + #[test] + fn none_removes_all_tools() { + assert!(enabled_tools("none").is_empty()); + } + + #[test] + fn all_includes_all_41_tools() { + assert_eq!(enabled_tools("all").len(), ALL_TOOLS.len()); + } + + #[test] + fn ro_keeps_only_read_tools() { + let tools = enabled_tools("default:ro"); + // Every enabled tool must be a read tool + for t in &tools { + let is_read = ALL_TOOLS.iter().find(|&&(n, _, _)| n == *t).unwrap().2; + assert!(is_read, "{t} should not be present in :ro mode"); + } + assert!(tools.contains("get_messages")); + assert!(!tools.contains("send_message")); + } + + #[test] + fn later_entry_overrides_earlier() { + // all:ro then default:rw → default tools are rw, rest are ro + let cfg = ToolsetConfig::parse("all:ro,default:rw"); + let remove = cfg.tools_to_remove(); + // send_message is default+write → should be kept (rw) + assert!(!remove.contains("send_message")); + // create_channel is channel_admin+write → should be removed (ro) + assert!(remove.contains("create_channel")); + // list_channel_members is channel_admin+read → should be kept (ro allows reads) + assert!(!remove.contains("list_channel_members")); + } + + #[test] + fn unknown_toolset_is_skipped_gracefully() { + // Should not panic; unknown toolset is silently ignored + let tools = enabled_tools("default,nonexistent_toolset"); + assert_eq!(tools.len(), 25); // only default + } + + #[test] + fn empty_input_enables_nothing() { + assert!(enabled_tools("").is_empty()); + } + + #[test] + fn none_after_all_clears() { + assert!(enabled_tools("all,none").is_empty()); + } + + #[test] + fn rw_suffix_is_same_as_bare() { + assert_eq!(enabled_tools("default:rw"), enabled_tools("default")); + } + + #[test] + fn all_tools_count_is_41() { + assert_eq!(ALL_TOOLS.len(), 41); + } + + #[test] + fn deferred_tools_count_is_3() { + assert_eq!(DEFERRED_TOOLS.len(), 3); + } + + #[test] + fn tools_in_toolset_returns_correct_tools() { + let tools = tools_in_toolset("canvas").unwrap(); + assert_eq!(tools.len(), 2); + let names: Vec<_> = tools.iter().map(|t| t.name).collect(); + assert!(names.contains(&"get_canvas")); + assert!(names.contains(&"set_canvas")); + } + + #[test] + fn tools_in_toolset_unknown_returns_none() { + assert!(tools_in_toolset("bogus").is_none()); + } + + #[test] + fn all_toolsets_returns_correct_count() { + // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity + // (media and realtime have no implemented tools yet) + let defs = all_toolsets(); + assert_eq!(defs.len(), 6); + let names: Vec<_> = defs.iter().map(|d| d.name).collect(); + assert!(names.contains(&"default")); + assert!(names.contains(&"canvas")); + } + + // ── Cross-check: ALL_TOOLS integrity ──────────────────────────────────── + + #[test] + fn all_tools_has_no_duplicates() { + let mut seen = std::collections::HashSet::new(); + for &(name, _, _) in ALL_TOOLS { + assert!( + seen.insert(name), + "duplicate tool name in ALL_TOOLS: {name}" + ); + } + } + + #[test] + fn all_tools_names_are_valid_identifiers() { + for &(name, _, _) in ALL_TOOLS { + assert!(!name.is_empty(), "empty tool name in ALL_TOOLS"); + assert!( + name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), + "invalid tool name in ALL_TOOLS: {name}" + ); + } + } + + // ── from_env empty-string fallback ────────────────────────────────────── + + #[test] + fn parse_empty_string_enables_nothing() { + // parse("") is the raw parser — empty input → no toolsets enabled. + // from_env() adds the fallback before calling parse, so agents always + // get at least the default toolset even when SPROUT_TOOLSETS="". + assert!(enabled_tools("").is_empty()); + } +}