mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(sprout-mcp): Toolset system — organize 41 tools into 8 configurable toolsets (#79)
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user