feat(sprout-mcp): Toolset system — organize 41 tools into 8 configurable toolsets (#79)

This commit is contained in:
tlongwell-block
2026-03-16 17:33:34 -04:00
committed by GitHub
parent 5a6a025216
commit fb58b39117
5 changed files with 670 additions and 219 deletions
+3 -3
View File
@@ -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."
+29 -22
View File
@@ -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;
+6 -1
View File
@@ -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?;
+196 -193
View File
@@ -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<String>,
/// If true and parent_event_id is set, surface the reply in the main channel timeline.
#[serde(default)]
pub broadcast_to_channel: Option<bool>,
/// Pubkeys to @mention in the message.
#[serde(default)]
pub mention_pubkeys: Option<Vec<String>>,
}
fn default_kind() -> Option<u16> {
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<bool>,
/// Unix timestamp cursor for pagination. Returns messages before this time.
#[serde(default)]
pub before: Option<i64>,
}
/// Parameters for the `list_channels` tool.
@@ -180,9 +189,9 @@ pub struct GetWorkflowRunsParams {
pub limit: Option<u32>,
}
/// 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<bool>,
/// 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<Vec<String>>,
}
/// 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<String>,
}
/// 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<String>,
}
/// 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<String>,
/// 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<Vec<String>>,
}
/// Parameters for the `search` tool.
@@ -456,28 +440,22 @@ pub struct GetFeedParams {
pub types: Option<String>,
}
/// 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<i64>,
/// Maximum items to return. Default 50, max 50.
#[serde(default)]
pub limit: Option<u32>,
/// 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<i64>,
/// Maximum items to return. Default 50, max 50.
#[serde(default)]
pub limit: Option<u32>,
/// 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<std::collections::HashSet<&'static str>>,
) -> 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<SendMessageParams>) -> 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<EditMessageParams>) -> 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<DeleteMessageParams>) -> 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<GetChannelHistoryParams>,
) -> String {
pub async fn get_messages(&self, Parameters(p): Parameters<GetMessagesParams>) -> 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<String> = 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<ApproveWorkflowStepParams>,
) -> String {
pub async fn approve_step(&self, Parameters(p): Parameters<ApproveStepParams>) -> 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<GetFeedMentionsParams>,
) -> 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<GetFeedActionsParams>,
) -> 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<SendReplyParams>) -> 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<GetUserProfileParams>,
) -> 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<GetUsersParams>) -> 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<GetUsersBatchParams>) -> 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}"),
}
}
}
}
+436
View File
@@ -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<Vec<ToolsetDef>> = LazyLock::new(|| {
let mut map: std::collections::BTreeMap<&'static str, Vec<ToolDef>> =
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<Vec<ToolDef>> {
let tools: Vec<ToolDef> = 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());
}
}