Files
buzz/crates/sprout-mcp/src/server.rs
tlongwell-block 1b87a0905f refactor: extract shared @mention resolver into sprout-sdk (#580)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <dawn@sprout.local>
2026-05-14 11:38:52 -04:00

3315 lines
127 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use nostr::{Alphabet, EventBuilder, EventId, Filter, JsonUtil, Kind, SingleLetterTag, Tag};
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router, ServerHandler,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::relay_client::RelayClient;
use sprout_core::kind;
use sprout_core::PresenceStatus;
/// Helper to create a lowercase single-letter tag for Nostr filter custom_tag.
fn tag_h() -> SingleLetterTag {
SingleLetterTag::lowercase(Alphabet::H)
}
fn tag_d() -> SingleLetterTag {
SingleLetterTag::lowercase(Alphabet::D)
}
fn tag_e() -> SingleLetterTag {
SingleLetterTag::lowercase(Alphabet::E)
}
fn tag_p() -> SingleLetterTag {
SingleLetterTag::lowercase(Alphabet::P)
}
/// Convert a sprout-core kind constant (u32) to a nostr Kind.
fn k(kind_num: u32) -> Kind {
Kind::Custom(kind_num as u16)
}
/// Percent-encode a string for safe inclusion in a URL query parameter value.
/// Encodes all characters except unreserved ones (A-Z a-z 0-9 - _ . ~).
#[cfg(test)]
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
// SAFETY: nibble values 0–15 are always valid hex digits.
let hi = char::from_digit((byte >> 4) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
let lo = char::from_digit((byte & 0xf) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
out.push('%');
out.push(hi);
out.push(lo);
}
}
}
out
}
/// Validate that a string is exactly 64 hex characters.
fn validate_hex64(s: &str, label: &str) -> Result<(), String> {
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
Err(format!(
"Error: {label} must be exactly 64 hex characters, got len={}",
s.len()
))
} else {
Ok(())
}
}
/// Validate that `s` is a well-formed UUID (any version/variant).
/// Returns `Ok(())` on success, or an error string on failure.
fn validate_uuid(s: &str) -> Result<(), String> {
uuid::Uuid::parse_str(s).map_err(|_| format!("invalid UUID: {s}"))?;
Ok(())
}
/// Extract the thread root event ID from a serialized Nostr tag array.
///
/// Parses `"e"` tags with NIP-10 markers:
/// - If a `"root"` marker exists, returns that event ID.
/// - If only a `"reply"` marker exists, returns the reply target (it IS the root
/// for a direct reply — needed so nested replies can supply the correct root).
/// - If no thread markers exist, returns `None` (top-level message).
fn find_root_from_tags(tags: &serde_json::Value) -> Option<String> {
let arr = tags.as_array()?;
let mut root = None;
let mut reply = None;
for tag in arr {
let Some(parts) = tag.as_array() else {
continue;
};
if parts.len() >= 4 && parts[0].as_str() == Some("e") {
match parts[3].as_str() {
Some("root") => root = parts[1].as_str().map(|s| s.to_string()),
Some("reply") => reply = parts[1].as_str().map(|s| s.to_string()),
_ => {}
}
}
}
root.or(reply)
}
/// Maximum allowed content size for a single message (64 KiB).
const MAX_CONTENT_BYTES: usize = 65_536;
/// Resolve `@name` mentions in `content` against this channel's members.
///
/// Performs the I/O (member + profile queries) and delegates parsing
/// and matching to [`sprout_sdk::mentions`]. On any query failure or
/// missing data, returns an empty vec — auto-tagging is best-effort
/// and must never block a send.
async fn resolve_content_mentions(
client: &RelayClient,
channel_id: &str,
content: &str,
) -> Vec<String> {
let names = sprout_sdk::mentions::extract_at_names(content);
if names.is_empty() {
return vec![];
}
// Query channel membership (kind 39002, addressed by `d` tag).
let filter = Filter::new()
.kind(k(kind::KIND_NIP29_GROUP_MEMBERS))
.custom_tag(tag_d(), [channel_id])
.limit(1);
let Ok(events) = client.query(vec![filter]).await else {
return vec![];
};
let Some(event) = events.first() else {
return vec![];
};
let member_pubkeys: Vec<&str> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p"))
.filter_map(|t| t.as_slice().get(1).map(|v| v.as_str()))
.collect();
if member_pubkeys.is_empty() {
return vec![];
}
// Fetch profiles for those members.
let authors: Vec<nostr::PublicKey> = member_pubkeys
.iter()
.filter_map(|pk| nostr::PublicKey::from_hex(pk).ok())
.collect();
let profile_filter = Filter::new()
.kind(k(kind::KIND_PROFILE))
.authors(authors)
.limit(member_pubkeys.len());
let Ok(profiles) = client.query(vec![profile_filter]).await else {
return vec![];
};
// Stable lowercase-hex strings for borrowing into MentionProfile.
let hex_pubkeys: Vec<String> = profiles.iter().map(|p| p.pubkey.to_hex()).collect();
let entries: Vec<sprout_sdk::mentions::MentionProfile<'_>> = profiles
.iter()
.zip(hex_pubkeys.iter())
.map(|(p, pk)| sprout_sdk::mentions::MentionProfile {
pubkey: pk.as_str(),
content_json: p.content.as_str(),
})
.collect();
sprout_sdk::mentions::match_names_to_profiles(&names, &entries)
}
/// Parameters for the `send_message` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SendMessageParams {
/// UUID of the channel to post to.
pub channel_id: String,
/// Message body text.
pub content: String,
/// Nostr event kind. Defaults to KIND_STREAM_MESSAGE (NIP-29 group chat message).
#[serde(default = "default_kind")]
pub kind: Option<u16>,
/// Optional parent event ID for threading. If provided, NIP-10 reply tags are added.
#[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>>,
/// Optional file paths to upload and attach as media. Each file is uploaded
/// to the relay and included as an imeta tag + markdown image in the message.
#[serde(default)]
pub file_paths: Option<Vec<String>>,
}
fn default_kind() -> Option<u16> {
Some(sprout_core::kind::KIND_STREAM_MESSAGE as u16)
}
/// Parameters for the `get_messages` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
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).
#[serde(default)]
pub limit: Option<u32>,
/// Legacy parameter (thread summaries are now always included). Kept for backward compatibility.
#[serde(default)]
pub with_threads: Option<bool>,
/// Unix timestamp cursor for pagination. Returns messages before this time.
#[serde(default)]
pub before: Option<i64>,
/// Unix timestamp cursor. Returns messages created strictly after this time.
/// When used without `before`, results are ordered oldest-first (chronological).
/// Useful for polling: pass the timestamp of the last seen message to get only newer ones.
#[serde(default)]
pub since: Option<i64>,
/// Comma-separated event kind numbers to filter by (e.g. "45001" for forum posts,
/// "45002" for votes). When omitted, all kinds are returned.
#[serde(default)]
pub kinds: Option<String>,
}
/// Parameters for the `list_channels` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ListChannelsParams {
/// Optional visibility filter: `"open"` or `"private"`.
#[serde(default)]
pub visibility: Option<String>,
}
/// Parameters for the `create_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CreateChannelParams {
/// Display name for the new channel.
pub name: String,
/// Channel type: `"stream"` (real-time chat) or `"forum"` (threaded discussions).
pub channel_type: String,
/// Channel visibility: `"open"` (anyone can join) or `"private"` (invite-only).
pub visibility: String,
/// Optional human-readable description of the channel's purpose.
#[serde(default)]
pub description: Option<String>,
}
/// Parameters for the `get_canvas` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetCanvasParams {
/// UUID of the channel whose canvas to retrieve.
pub channel_id: String,
}
/// Parameters for the `set_canvas` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetCanvasParams {
/// UUID of the channel whose canvas to update.
pub channel_id: String,
/// New canvas content (replaces any existing canvas).
pub content: String,
}
// ── Workflow tool parameter structs ──────────────────────────────────────────
/// Parameters for the `list_workflows` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ListWorkflowsParams {
/// UUID of the channel whose workflows to list.
pub channel_id: String,
}
/// Parameters for the `create_workflow` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct CreateWorkflowParams {
/// UUID of the channel to own this workflow.
pub channel_id: String,
/// Full workflow definition in YAML format. Required fields: name (string), trigger (object with
/// 'on' field: 'message_posted', 'diff_posted', 'reaction_added', or 'webhook'), steps (array).
/// Each step needs: id (alphanumeric/underscore), action (e.g. 'send_message'), and action-specific
/// fields as direct properties (NOT nested under 'params'). Example:
/// ```yaml
/// name: My Workflow
/// trigger:
/// on: message_posted
/// steps:
/// - id: notify
/// action: send_message
/// text: Hello from workflow!
/// ```
pub yaml_definition: String,
}
/// Parameters for the `update_workflow` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UpdateWorkflowParams {
/// UUID of the workflow to update.
pub workflow_id: String,
/// Replacement YAML definition.
pub yaml_definition: String,
}
/// Parameters for the `delete_workflow` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct DeleteWorkflowParams {
/// UUID of the workflow to delete.
pub workflow_id: String,
}
/// Parameters for the `trigger_workflow` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TriggerWorkflowParams {
/// UUID of the workflow to trigger.
pub workflow_id: String,
/// Optional JSON object of input variables passed to the workflow.
#[serde(default)]
pub inputs: Option<serde_json::Value>,
}
/// Parameters for the `get_workflow_runs` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetWorkflowRunsParams {
/// UUID of the workflow whose run history to fetch.
pub workflow_id: String,
/// Maximum number of runs to return. Default 20, max 100.
#[serde(default)]
pub limit: Option<u32>,
}
/// Parameters for the `approve_step` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ApproveStepParams {
/// Opaque approval token from the kind:46010 event.
pub approval_token: String,
/// true = approve, false = deny.
pub approved: bool,
/// Optional human-readable note to attach to the decision.
#[serde(default)]
pub note: Option<String>,
}
// ── Feed tool parameter structs ───────────────────────────────────────────────
// ── Membership tool parameter structs ────────────────────────────────────────
/// Parameters for the `add_channel_member` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AddChannelMemberParams {
/// UUID of the channel.
pub channel_id: String,
/// Hex-encoded public key of the user to add.
pub pubkey: String,
/// Role to assign: `"member"` (default) or `"admin"`.
#[serde(default)]
pub role: Option<String>,
}
/// Parameters for the `remove_channel_member` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct RemoveChannelMemberParams {
/// UUID of the channel.
pub channel_id: String,
/// Hex-encoded public key of the user to remove.
pub pubkey: String,
}
/// Parameters for the `list_channel_members` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ListChannelMembersParams {
/// UUID of the channel whose members to list.
pub channel_id: String,
}
/// Parameters for the `join_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct JoinChannelParams {
/// UUID of the channel to join.
pub channel_id: String,
}
/// Parameters for the `leave_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct LeaveChannelParams {
/// UUID of the channel to leave.
pub channel_id: String,
}
/// Parameters for the `get_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetChannelParams {
/// UUID of the channel to retrieve.
pub channel_id: String,
}
// ── Metadata tool parameter structs ──────────────────────────────────────────
/// Parameters for the `update_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UpdateChannelParams {
/// UUID of the channel to update.
pub channel_id: String,
/// New display name for the channel.
#[serde(default)]
pub name: Option<String>,
/// New description for the channel.
#[serde(default)]
pub description: Option<String>,
}
/// Parameters for the `set_channel_topic` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetChannelTopicParams {
/// UUID of the channel.
pub channel_id: String,
/// New topic string.
pub topic: String,
}
/// Parameters for the `set_channel_purpose` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetChannelPurposeParams {
/// UUID of the channel.
pub channel_id: String,
/// New purpose string.
pub purpose: String,
}
/// Parameters for the `archive_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ArchiveChannelParams {
/// UUID of the channel to archive.
pub channel_id: String,
}
/// Parameters for the `unarchive_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UnarchiveChannelParams {
/// UUID of the channel to unarchive.
pub channel_id: String,
}
/// Parameters for the `delete_channel` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct DeleteChannelParams {
/// UUID of the channel to permanently delete.
pub channel_id: String,
}
// ── Thread tool parameter structs ─────────────────────────────────────────────
/// Parameters for the `get_thread` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetThreadParams {
/// UUID of the channel containing the thread.
pub channel_id: String,
/// Event ID of the root (or any ancestor) message of the thread.
pub event_id: String,
/// Maximum nesting depth to return (default: unlimited).
#[serde(default)]
pub depth_limit: Option<u32>,
/// Maximum number of replies to return (default 50).
#[serde(default)]
pub limit: Option<u32>,
}
// ── DM tool parameter structs ─────────────────────────────────────────────────
/// Parameters for the `open_dm` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct OpenDmParams {
/// Hex-encoded public keys of the other participants (1–8).
pub pubkeys: Vec<String>,
}
/// Parameters for the `add_dm_member` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AddDmMemberParams {
/// UUID of the DM channel.
pub channel_id: String,
/// Hex-encoded public key of the user to add.
pub pubkey: String,
}
/// Parameters for the `hide_dm` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct HideDmParams {
/// UUID of the DM channel to hide.
pub channel_id: String,
}
// ── Reaction tool parameter structs ──────────────────────────────────────────
/// Parameters for the `add_reaction` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct AddReactionParams {
/// Event ID of the message to react to.
pub event_id: String,
/// Emoji to react with (e.g. `"👍"` or `":thumbsup:"`).
pub emoji: String,
}
/// Parameters for the `remove_reaction` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct RemoveReactionParams {
/// Event ID of the message whose reaction to remove.
pub event_id: String,
/// Emoji to remove.
pub emoji: String,
}
/// Parameters for the `get_reactions` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetReactionsParams {
/// Event ID of the message whose reactions to fetch.
pub event_id: String,
}
// ── User profile tool parameter structs ──────────────────────────────────────
/// Parameters for the `set_profile` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetProfileParams {
/// New display name for the agent's profile.
#[serde(default)]
pub display_name: Option<String>,
/// URL of the agent's avatar image.
#[serde(default)]
pub avatar_url: Option<String>,
/// Short bio or description.
#[serde(default)]
pub about: Option<String>,
/// NIP-05 identifier (e.g. "alice@example.com"), or None to leave unchanged.
#[serde(default)]
pub nip05_handle: Option<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.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
/// Full-text search query string.
pub q: String,
/// Maximum results to return (default 20, max 100).
#[serde(default)]
pub limit: Option<u32>,
}
/// Parameters for the `get_presence` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetPresenceParams {
/// Comma-separated hex-encoded public keys to look up presence for (max 200).
pub pubkeys: String,
}
/// Parameters for the `set_presence` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetPresenceParams {
/// Presence status to set.
pub status: PresenceStatus,
}
/// Parameters for the `set_channel_add_policy` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetChannelAddPolicyParams {
/// Channel add policy: "anyone" (default), "owner_only", or "nobody".
/// - "anyone": any authenticated user can add you to open channels.
/// - "owner_only": only your provisioned owner can add you.
/// - "nobody": no one can add you; you must self-join channels.
pub policy: String,
}
/// Parameters for the `get_feed` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetFeedParams {
/// Only return feed items newer than this Unix timestamp.
/// Defaults to now - 7 days if omitted.
#[serde(default)]
pub since: Option<i64>,
/// Maximum items per category. Default 50, max 50.
#[serde(default)]
pub limit: Option<u32>,
/// Comma-separated category filter: "mentions,needs_action,activity,agent_activity".
/// Omit to return all categories.
#[serde(default)]
pub types: Option<String>,
}
/// 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 `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.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SendDiffMessageParams {
/// UUID of the channel to post to.
pub channel_id: String,
/// Unified diff content (git diff format).
pub diff: String,
/// URL of the source repository (e.g. "https://github.com/org/repo").
pub repo_url: String,
/// Full commit SHA this diff applies to.
pub commit_sha: String,
/// Optional file path within the repo (used for language inference and display).
#[serde(default)]
pub file_path: Option<String>,
/// Optional parent commit SHA (the base of the diff).
#[serde(default)]
pub parent_commit_sha: Option<String>,
/// Optional source branch name (e.g. "feat/my-feature").
#[serde(default)]
pub source_branch: Option<String>,
/// Optional target branch name (e.g. "main").
#[serde(default)]
pub target_branch: Option<String>,
/// Optional pull request number associated with this diff.
#[serde(default)]
pub pr_number: Option<u32>,
/// Optional language hint for syntax highlighting (e.g. "rust", "typescript").
/// Inferred from file_path extension if omitted.
#[serde(default)]
pub language: Option<String>,
/// Optional human-readable description of the change.
#[serde(default)]
pub description: Option<String>,
/// Optional parent event ID. If provided, sends the diff as a threaded reply.
#[serde(default)]
pub parent_event_id: Option<String>,
}
/// Vote direction for forum posts.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum VoteDirection {
/// Upvote.
Up,
/// Downvote.
Down,
}
/// Parameters for the `vote_on_post` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct VoteOnPostParams {
/// UUID of the forum channel.
pub channel_id: String,
/// 64-character hex event ID of the post or comment being voted on.
pub event_id: String,
/// Vote direction.
pub direction: VoteDirection,
}
/// Parameters for [`SproutServer::publish_note`].
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PublishNoteParams {
/// Text content of the note (max 64 KiB).
pub content: String,
/// 64-char hex event ID to reply to. Adds a single e-tag with "reply" marker.
#[serde(default)]
pub reply_to_event_id: Option<String>,
}
/// A single contact entry for [`SetContactListParams`].
///
/// Kept local to MCP — not part of sprout-sdk. The SDK builder takes primitive slices.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ContactEntry {
/// 64-char hex pubkey (any case accepted, normalized to lowercase).
pub pubkey: String,
/// Optional relay URL hint (NIP-02). Empty string if omitted.
#[serde(default)]
pub relay_url: Option<String>,
/// Optional petname / display alias.
#[serde(default)]
pub petname: Option<String>,
}
/// Parameters for [`SproutServer::set_contact_list`].
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SetContactListParams {
/// Replaces the **entire** contact list. Call `get_contact_list` first for delta updates.
pub contacts: Vec<ContactEntry>,
}
/// Parameters for [`SproutServer::get_event`].
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetEventParams {
/// 64-char hex event ID.
pub event_id: String,
}
/// Parameters for [`SproutServer::get_user_notes`].
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetUserNotesParams {
/// 64-char hex pubkey of the author.
pub pubkey: String,
/// Maximum number of notes to return (default 50, max 100).
#[serde(default)]
pub limit: Option<u32>,
/// Unix timestamp cursor — return notes created before this time.
/// Use with `before_id` for stable composite cursor pagination.
#[serde(default)]
pub before: Option<i64>,
/// Hex event ID cursor for composite keyset pagination. Use together with
/// `before` to avoid skipping same-second events. Pass the `before_id` value
/// from the previous page's `next_cursor` response.
#[serde(default)]
pub before_id: Option<String>,
}
/// Parameters for [`SproutServer::get_contact_list`].
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct GetContactListParams {
/// 64-char hex pubkey of the user whose contact list to fetch.
pub pubkey: String,
}
// ── Diff utility functions ────────────────────────────────────────────────────
// Truncation notice appended when a diff is cut. This constant is used to
// reserve space so the final result never exceeds max_bytes.
// NOTE: This function is only called with max_bytes = 60 * 1024, so the
// hardcoded "60KB" in the notice is intentional and always accurate.
const TRUNCATION_NOTICE: &str =
"\n\\ Diff truncated at 60KB. Full diff available at the source repository.";
/// Truncate a diff to at most `max_bytes` bytes, cutting at a hunk boundary
/// where possible. Returns the (possibly truncated) string and a flag indicating
/// whether truncation occurred.
///
/// The truncation notice is included within the `max_bytes` budget — the
/// returned string is guaranteed to be `<= max_bytes` in length.
fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) {
debug_assert!(
max_bytes >= TRUNCATION_NOTICE.len(),
"max_bytes ({max_bytes}) must be >= TRUNCATION_NOTICE length ({})",
TRUNCATION_NOTICE.len()
);
if diff.len() <= max_bytes {
return (diff.to_string(), false);
}
// Reserve space for the truncation notice so the final result stays within max_bytes.
let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len());
// Step 1: Find the last UTF-8 char boundary at or before effective_limit
let utf8_boundary = diff
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= effective_limit)
.last()
.unwrap_or(0);
// Step 2: Within safe prefix, find last complete hunk boundary
let safe_prefix = &diff[..utf8_boundary];
let last_hunk_start = safe_prefix.rfind("\n@@");
let cut_point = match last_hunk_start {
Some(pos) if pos > 0 => pos,
_ => safe_prefix.rfind('\n').unwrap_or(utf8_boundary),
};
let mut result = diff[..cut_point].to_string();
result.push_str(TRUNCATION_NOTICE);
(result, true)
}
/// Infer a language name from a file path's extension for syntax highlighting.
/// Returns `None` if the extension is unknown or absent.
fn infer_language(file_path: &str) -> Option<String> {
// Note: rsplit always yields at least one element (the full string if no '.' found),
// so .next() always returns Some. The ? is effectively a no-op here.
let ext = file_path.rsplit('.').next()?;
let lang = match ext {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"rb" => "ruby",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" => "cpp",
"cs" => "csharp",
"swift" => "swift",
"kt" | "kts" => "kotlin",
"scala" => "scala",
"sh" | "bash" | "zsh" => "bash",
"sql" => "sql",
"html" | "htm" => "html",
"css" | "scss" | "sass" => "css",
"json" => "json",
"yaml" | "yml" => "yaml",
"toml" => "toml",
"xml" => "xml",
"md" | "markdown" => "markdown",
"dockerfile" => "dockerfile",
_ => return None,
};
Some(lang.to_string())
}
/// Parameters for the `upload_file` tool.
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UploadFileParams {
/// Local filesystem path to the file to upload.
pub file_path: String,
}
/// The MCP server that exposes Sprout relay functionality as tools.
#[derive(Clone)]
pub struct SproutMcpServer {
client: RelayClient,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl SproutMcpServer {
/// Create a new [`SproutMcpServer`] backed by the given relay client.
///
/// 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,
}
}
/// Resolve a `ThreadRef` for SDK builders by fetching the parent event.
///
/// Determines root vs. parent for NIP-10 markers:
/// - Direct reply: root == parent
/// - Nested reply: root is the thread root, parent is the immediate reply target
async fn resolve_thread_ref(
&self,
parent_event_id: &str,
parent_eid: EventId,
) -> Result<sprout_sdk::ThreadRef, String> {
let filter = Filter::new().id(parent_eid).limit(1);
let events = self
.client
.query(vec![filter])
.await
.map_err(|e| format!("failed to fetch parent event: {e}"))?;
let parent_event = events
.first()
.ok_or_else(|| "parent event not found".to_string())?;
let tags_json = serde_json::to_value(&parent_event.tags)
.map_err(|e| format!("failed to serialize tags: {e}"))?;
let root_eid = match find_root_from_tags(&tags_json) {
Some(root_hex) if root_hex != parent_event_id => EventId::from_hex(&root_hex)
.map_err(|e| format!("failed to parse root event id: {e}"))?,
_ => parent_eid,
};
Ok(sprout_sdk::ThreadRef {
root_event_id: root_eid,
parent_event_id: parent_eid,
})
}
/// Send a message to a Sprout channel.
#[tool(
name = "send_message",
description = "Send a message to a Sprout channel. Include `parent_event_id` to reply in a thread. \
Set `broadcast_to_channel` to also surface the reply in the main channel timeline. \
For forum channels, set `kind` to 45001 (post) or 45003 (comment with `parent_event_id`). \
Default kind is 9 (stream message)."
)]
pub async fn send_message(&self, Parameters(p): Parameters<SendMessageParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
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()
);
}
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
);
}
}
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let kind_num = p
.kind
.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE as u16);
// Normalize explicit mentions, then merge auto-resolved `@names` from
// the body up to the SDK mention cap.
let explicit = p.mention_pubkeys.as_deref().unwrap_or(&[]);
let mut mentions = sprout_sdk::mentions::normalize_mention_pubkeys(explicit, None);
let auto = resolve_content_mentions(&self.client, &p.channel_id, &p.content).await;
sprout_sdk::mentions::merge_mentions(
&mut mentions,
&auto,
sprout_sdk::mentions::MENTION_CAP,
);
let mention_refs: Vec<&str> = mentions.iter().map(String::as_str).collect();
let broadcast = p.broadcast_to_channel.unwrap_or(false);
// Upload files and build media tags
let mut media_tags: Vec<Vec<String>> = Vec::new();
let mut media_content = String::new();
if let Some(ref paths) = p.file_paths {
for path in paths {
match crate::upload::upload_file(
self.client.http_client(),
self.client.keys(),
&self.client.relay_http_url(),
self.client.server_domain().as_deref(),
path,
self.client.auth_tag_json().as_deref(),
)
.await
{
Ok(desc) => {
media_tags.push(crate::upload::build_imeta_tag(&desc));
if desc.mime_type.starts_with("video/") {
media_content.push_str("\n![video](");
} else {
media_content.push_str("\n![image](");
}
media_content.push_str(&desc.url);
media_content.push(')');
}
Err(e) => {
return format!("Error uploading {}: {e}", path);
}
}
}
}
let final_content = if media_content.is_empty() {
p.content.clone()
} else {
format!("{}{}", p.content, media_content)
};
// Build the event builder via SDK, routing by kind.
let builder = match kind_num as u32 {
sprout_core::kind::KIND_FORUM_POST => {
// kind 45001: forum post (no thread ref, no broadcast)
match sprout_sdk::build_forum_post(
channel_uuid,
&final_content,
&mention_refs,
&media_tags,
) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
}
}
sprout_core::kind::KIND_FORUM_COMMENT => {
// kind 45003: forum comment — requires parent_event_id
let parent_id = match p.parent_event_id.as_deref() {
Some(id) => id,
None => return "Error: kind 45003 requires parent_event_id".to_string(),
};
let parent_eid = match EventId::from_hex(parent_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid parent_event_id: {e}"),
};
// Fetch parent to resolve thread root for NIP-10 markers.
let thread_ref = match self.resolve_thread_ref(parent_id, parent_eid).await {
Ok(tr) => tr,
Err(e) => return format!("Error: {e}"),
};
match sprout_sdk::build_forum_comment(
channel_uuid,
&final_content,
&thread_ref,
&mention_refs,
&media_tags,
) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
}
}
_ => {
// kind 9 (default) and any other stream message kinds.
let thread_ref = if let Some(ref parent_id) = p.parent_event_id {
let parent_eid = match EventId::from_hex(parent_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid parent_event_id: {e}"),
};
match self.resolve_thread_ref(parent_id, parent_eid).await {
Ok(tr) => Some(tr),
Err(e) => return format!("Error: {e}"),
}
} else {
None
};
match sprout_sdk::build_message(
channel_uuid,
&final_content,
thread_ref.as_ref(),
&mention_refs,
broadcast && p.parent_event_id.is_some(),
&media_tags,
) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
}
}
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign message 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}"),
}
}
/// Send a code diff to a Sprout channel as kind:40008.
#[tool(
name = "send_diff_message",
description = "Send a code diff to a Sprout channel with syntax highlighting and structured metadata. \
Include `parent_event_id` to post the diff as a thread reply. \
The diff is rendered with GitHub-quality visualization in the desktop client."
)]
pub async fn send_diff_message(
&self,
Parameters(p): Parameters<SendDiffMessageParams>,
) -> String {
let SendDiffMessageParams {
channel_id,
diff,
repo_url,
commit_sha,
file_path,
parent_commit_sha,
source_branch,
target_branch,
pr_number,
language,
description,
parent_event_id,
} = p;
if let Err(e) = validate_uuid(&channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {channel_id}"),
};
// 1. Truncate diff at 60KB (UTF-8 safe)
let (diff_content, truncated) = truncate_diff(&diff, 60 * 1024);
// 2. Infer language from file extension if not provided
let lang = language.or_else(|| file_path.as_deref().and_then(infer_language));
// 3. Build NIP-31 alt text
let alt_text = match &description {
Some(desc) => format!(
"Diff: {} — {}",
file_path.as_deref().unwrap_or("diff"),
desc
),
None => format!("Diff: {}", file_path.as_deref().unwrap_or("diff")),
};
// 4. Warn on partial branch metadata (both or neither required)
match (&source_branch, &target_branch) {
(Some(_), None) | (None, Some(_)) => {
tracing::warn!("send_diff_message: only one of source_branch/target_branch provided — both required, branch metadata omitted");
}
_ => {}
}
let branch = match (source_branch, target_branch) {
(Some(src), Some(tgt)) => Some((src, tgt)),
_ => None,
};
// 5. Resolve optional thread ref
let thread_ref = if let Some(ref parent_id) = parent_event_id {
let parent_eid = match EventId::from_hex(parent_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid parent_event_id: {e}"),
};
match self.resolve_thread_ref(parent_id, parent_eid).await {
Ok(tr) => Some(tr),
Err(e) => return format!("Error: {e}"),
}
} else {
None
};
// 6. Build signed event via SDK
let diff_meta = sprout_sdk::DiffMeta {
repo_url,
commit_sha,
file_path,
parent_commit: parent_commit_sha,
branch,
pr_number,
language: lang,
description,
truncated,
alt_text: Some(alt_text),
};
let builder = match sprout_sdk::build_diff_message(
channel_uuid,
&diff_content,
&diff_meta,
thread_ref.as_ref(),
) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign diff 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}"),
}
}
/// 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 channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let target_eid = match EventId::from_hex(&p.event_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid event_id: {e}"),
};
let builder = match sprout_sdk::build_edit(channel_uuid, target_eid, &p.content) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
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 target_eid = match EventId::from_hex(&p.event_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid event_id: {e}"),
};
// Fetch the event to extract its channel_id (h-tag) — required by build_delete_message.
let filter = Filter::new().id(target_eid).limit(1);
let fetched = match self.client.query(vec![filter]).await {
Ok(e) => e,
Err(e) => return format!("Error: failed to fetch event: {e}"),
};
let fetched_event = match fetched.first() {
Some(e) => e,
None => return format!("Error: event '{}' not found", p.event_id),
};
let channel_id_str = match fetched_event
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("h"))
.and_then(|t| t.as_slice().get(1).map(|v| v.to_string()))
{
Some(id) => id,
None => return "Error: could not find channel_id (h-tag) on event".to_string(),
};
let channel_uuid = match uuid::Uuid::parse_str(&channel_id_str) {
Ok(u) => u,
Err(_) => return format!("Error: event h-tag is not a valid UUID: {channel_id_str}"),
};
let builder = match sprout_sdk::build_delete_message(channel_uuid, target_eid) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign delete 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}"),
}
}
/// Get recent messages from a Sprout channel.
#[tool(
name = "get_messages",
description = "Fetch recent top-level messages from a Sprout channel. Use `before` for backward \
pagination and `since` for forward pagination (both Unix timestamps). When `since` is \
provided without `before`, results are ordered oldest-first — useful for polling new \
messages. Use `kinds` to filter by event type (e.g. \"45001\" for forum posts, \
\"45002\" for votes). Thread summaries are included automatically. Threaded replies \
are not returned — use `get_thread` to fetch the full reply tree for a specific message."
)]
pub async fn get_messages(&self, Parameters(p): Parameters<GetMessagesParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
const MAX_HISTORY_LIMIT: usize = 200;
let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT as u32) as usize;
// Build filter for channel messages (stream messages, edits, diffs, forum posts/comments).
let message_kinds: Vec<Kind> = if let Some(ref kinds_str) = p.kinds {
kinds_str
.split(',')
.filter_map(|s| s.trim().parse::<u16>().ok())
.map(Kind::from)
.collect()
} else {
vec![
k(kind::KIND_STREAM_MESSAGE),
k(kind::KIND_STREAM_MESSAGE_V2),
k(kind::KIND_STREAM_MESSAGE_DIFF),
k(kind::KIND_FORUM_POST),
k(kind::KIND_FORUM_COMMENT),
]
};
let mut filter = Filter::new()
.kinds(message_kinds)
.custom_tag(tag_h(), [&p.channel_id])
.limit(limit);
if let Some(before) = p.before {
filter = filter.until(nostr::Timestamp::from(before as u64));
}
if let Some(since) = p.since {
filter = filter.since(nostr::Timestamp::from(since as u64));
}
match self.client.query(vec![filter]).await {
Ok(mut events) => {
events.sort_by_key(|e| e.created_at);
let result: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.id.to_hex(),
"pubkey": e.pubkey.to_hex(),
"kind": e.kind.as_u16(),
"content": e.content,
"created_at": e.created_at.as_u64(),
"tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
})
})
.collect();
serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
/// List Sprout channels accessible to this agent.
#[tool(
name = "list_channels",
description = "List Sprout channels accessible to this agent"
)]
pub async fn list_channels(&self, Parameters(p): Parameters<ListChannelsParams>) -> String {
// Query channel metadata events (kind:41 = NIP-29 group metadata).
let filter = Filter::new()
.kind(k(kind::KIND_CHANNEL_METADATA))
.limit(500);
match self.client.query(vec![filter]).await {
Ok(events) => {
let channels: Vec<serde_json::Value> = events
.iter()
.filter(|e| {
if let Some(ref vis) = p.visibility {
// Filter by visibility tag if specified
e.tags
.iter()
.any(|t| {
let s = t.as_slice();
s.first().map(|v| v.as_str()) == Some("visibility")
&& s.get(1).map(|v| v.as_str()) == Some(vis.as_str())
})
} else {
true
}
})
.map(|e| {
let content: serde_json::Value =
serde_json::from_str(&e.content).unwrap_or(serde_json::json!({}));
let channel_id = e
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d"))
.and_then(|t| t.as_slice().get(1))
.map(|s| s.to_string())
.unwrap_or_default();
serde_json::json!({
"channel_id": channel_id,
"name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""),
"description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.created_at.as_u64(),
})
})
.collect();
serde_json::to_string(&channels).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
/// Create a new Sprout channel.
#[tool(
name = "create_channel",
description = "Create a new Sprout channel. channel_type must be 'stream' or 'forum'. visibility must be 'open' or 'private'."
)]
pub async fn create_channel(&self, Parameters(p): Parameters<CreateChannelParams>) -> String {
let channel_uuid = uuid::Uuid::new_v4();
let visibility = match p.visibility.as_str() {
"open" => sprout_sdk::Visibility::Open,
"private" => sprout_sdk::Visibility::Private,
other => {
return format!(
"Error: invalid visibility: {other:?} (must be 'open' or 'private')"
)
}
};
let channel_type = match p.channel_type.as_str() {
"stream" => sprout_sdk::ChannelKind::Stream,
"forum" => sprout_sdk::ChannelKind::Forum,
other => {
return format!(
"Error: invalid channel_type: {other:?} (must be 'stream' or 'forum')"
)
}
};
let builder = match sprout_sdk::build_create_channel(
channel_uuid,
&p.name,
Some(visibility),
Some(channel_type),
p.description.as_deref(),
) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign create_channel event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) => serde_json::json!({
"event_id": ok.event_id,
"channel_id": channel_uuid.to_string(),
"accepted": ok.accepted,
"message": ok.message,
})
.to_string(),
Err(e) => format!("Error: {e}"),
}
}
/// Get the canvas (shared document) for a channel.
#[tool(
name = "get_canvas",
description = "Get the canvas (shared document) for a channel"
)]
pub async fn get_canvas(&self, Parameters(p): Parameters<GetCanvasParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
// Canvas is kind:40100 with #h tag = channel_id
let filter = Filter::new()
.kind(k(kind::KIND_CANVAS))
.custom_tag(tag_h(), [&p.channel_id])
.limit(1);
match self.client.query(vec![filter]).await {
Ok(events) => match events.first() {
Some(event) => event.content.clone(),
None => "No canvas set for this channel.".to_string(),
},
Err(e) => format!("Error: {e}"),
}
}
/// Set or update the canvas (shared document) for a channel.
#[tool(
name = "set_canvas",
description = "Set or update the canvas (shared document) for a channel"
)]
pub async fn set_canvas(&self, Parameters(p): Parameters<SetCanvasParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_set_canvas(channel_uuid, &p.content) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign set_canvas 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}"),
}
}
// ── Workflow tools ────────────────────────────────────────────────────────
/// List workflows defined in a Sprout channel.
#[tool(
name = "list_workflows",
description = "List workflows defined in a Sprout channel"
)]
pub async fn list_workflows(&self, Parameters(p): Parameters<ListWorkflowsParams>) -> String {
if uuid::Uuid::parse_str(&p.channel_id).is_err() {
return format!("Error: channel_id '{}' is not a valid UUID", p.channel_id);
}
// Workflows are kind:30620 (param-replaceable) with #h tag = channel_id
let filter = Filter::new()
.kind(k(kind::KIND_WORKFLOW_DEF))
.custom_tag(tag_h(), [&p.channel_id])
.limit(100);
match self.client.query(vec![filter]).await {
Ok(events) => {
let workflows: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let d_tag = e
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d"))
.and_then(|t| t.as_slice().get(1))
.map(|s| s.to_string())
.unwrap_or_default();
serde_json::json!({
"workflow_id": d_tag,
"content": e.content,
"created_at": e.created_at.as_u64(),
"pubkey": e.pubkey.to_hex(),
})
})
.collect();
serde_json::to_string(&workflows).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
/// Create a new workflow in a channel from a YAML definition.
#[tool(
name = "create_workflow",
description = "Create a new workflow from a YAML definition. Steps need 'id' (not 'name'), and action fields are direct properties (not nested under 'params'). Triggers: message_posted, reaction_added, webhook."
)]
pub async fn create_workflow(&self, Parameters(p): Parameters<CreateWorkflowParams>) -> String {
if uuid::Uuid::parse_str(&p.channel_id).is_err() {
return format!("Error: channel_id '{}' is not a valid UUID", p.channel_id);
}
// Workflow definition is a kind:30620 (param-replaceable) event.
// d-tag = workflow UUID, h-tag = channel_id, content = YAML.
let workflow_id = uuid::Uuid::new_v4().to_string();
let tags = vec![
Tag::parse(&["d", &workflow_id]).unwrap(),
Tag::parse(&["h", &p.channel_id]).unwrap(),
];
let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_DEF), &p.yaml_definition, tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign workflow event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) => serde_json::json!({
"event_id": ok.event_id,
"workflow_id": workflow_id,
"accepted": ok.accepted,
"message": ok.message,
})
.to_string(),
Err(e) => format!("Error: {e}"),
}
}
/// Replace a workflow's YAML definition.
#[tool(
name = "update_workflow",
description = "Replace a workflow's YAML definition"
)]
pub async fn update_workflow(&self, Parameters(p): Parameters<UpdateWorkflowParams>) -> String {
if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
}
// Publish a new kind:30620 event with the same d-tag to replace the existing one.
let tags = vec![Tag::parse(&["d", &p.workflow_id]).unwrap()];
let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_DEF), &p.yaml_definition, tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign workflow 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 workflow by ID.
#[tool(name = "delete_workflow", description = "Delete a workflow by ID")]
pub async fn delete_workflow(&self, Parameters(p): Parameters<DeleteWorkflowParams>) -> String {
if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
}
// Delete via kind:5 event referencing the workflow's d-tag coordinate.
let coordinate = format!(
"{}:{}:{}",
kind::KIND_WORKFLOW_DEF,
self.client.pubkey_hex(),
p.workflow_id
);
let tags = vec![Tag::parse(&["a", &coordinate]).unwrap()];
let builder = EventBuilder::new(k(kind::KIND_DELETION), "", tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign deletion event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) if ok.accepted => "Workflow deleted.".to_string(),
Ok(ok) => format!("Error: relay rejected deletion: {}", ok.message),
Err(e) => format!("Error: {e}"),
}
}
/// Manually trigger a workflow with optional input variables.
#[tool(
name = "trigger_workflow",
description = "Manually trigger a workflow with optional input variables"
)]
pub async fn trigger_workflow(
&self,
Parameters(p): Parameters<TriggerWorkflowParams>,
) -> String {
if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
}
let inputs = p
.inputs
.unwrap_or(serde_json::Value::Object(Default::default()));
let content = serde_json::to_string(&inputs).unwrap_or_default();
let tags = vec![Tag::parse(&["d", &p.workflow_id]).unwrap()];
let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_TRIGGER), &content, tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign trigger 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}"),
}
}
/// Get execution history for a workflow.
#[tool(
name = "get_workflow_runs",
description = "Get execution history for a workflow"
)]
pub async fn get_workflow_runs(
&self,
Parameters(p): Parameters<GetWorkflowRunsParams>,
) -> String {
if uuid::Uuid::parse_str(&p.workflow_id).is_err() {
return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id);
}
let limit = p.limit.unwrap_or(20).min(100) as usize;
// Query workflow execution events (kind:46001–46010) referencing this workflow.
let filter = Filter::new()
.kinds(vec![
k(kind::KIND_WORKFLOW_TRIGGERED),
k(kind::KIND_WORKFLOW_TRIGGERED + 1), // completed
k(kind::KIND_WORKFLOW_TRIGGERED + 2), // failed
])
.custom_tag(tag_d(), [&p.workflow_id])
.limit(limit);
match self.client.query(vec![filter]).await {
Ok(events) => {
let runs: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"event_id": e.id.to_hex(),
"kind": e.kind.as_u16(),
"content": e.content,
"created_at": e.created_at.as_u64(),
"tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
})
})
.collect();
serde_json::to_string(&runs).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
/// Approve or deny a pending workflow approval step.
#[tool(
name = "approve_step",
description = "Approve or deny a pending workflow approval step"
)]
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",
p.approval_token
);
}
let kind_num = if p.approved {
kind::KIND_APPROVAL_GRANT
} else {
kind::KIND_APPROVAL_DENY
};
let content = p.note.as_deref().unwrap_or("");
// The relay expects d-tag = hex(SHA256(token)), not the raw token UUID.
let token_hash = hex::encode(Sha256::digest(p.approval_token.as_bytes()));
let tags = vec![Tag::parse(&["d", &token_hash]).unwrap()];
let builder = EventBuilder::new(k(kind_num), content, tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign approval 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}"),
}
}
// ── Feed tools ────────────────────────────────────────────────────────────
/// Get the agent's personalized home feed from the Sprout relay.
#[tool(
name = "get_feed",
description = "Get the agent's personalized home feed from the Sprout relay. \
Returns mentions, needs-action items, channel activity, and agent activity. \
Equivalent to what a human sees on the Home tab in the desktop app."
)]
pub async fn get_feed(&self, Parameters(p): Parameters<GetFeedParams>) -> String {
const MAX_FEED_LIMIT: usize = 50;
let limit = p
.limit
.map(|l| l.min(MAX_FEED_LIMIT as u32) as usize)
.unwrap_or(MAX_FEED_LIMIT);
// Query events that mention this agent (p-tag) or are in channels we're in.
let my_pubkey = self.client.pubkey_hex();
let mut filter = Filter::new().custom_tag(tag_p(), [&my_pubkey]).limit(limit);
if let Some(since) = p.since {
filter = filter.since(nostr::Timestamp::from(since as u64));
}
match self.client.query(vec![filter]).await {
Ok(mut events) => {
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
let feed: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.id.to_hex(),
"pubkey": e.pubkey.to_hex(),
"kind": e.kind.as_u16(),
"content": e.content,
"created_at": e.created_at.as_u64(),
"tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
})
})
.collect();
serde_json::to_string(&feed).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error fetching feed: {e}"),
}
}
// ── Membership tools ──────────────────────────────────────────────────────
/// Add a member to a channel.
#[tool(
name = "add_channel_member",
description = "Add a member to a Sprout channel. Optionally specify a role (default: \"member\")."
)]
pub async fn add_channel_member(
&self,
Parameters(p): Parameters<AddChannelMemberParams>,
) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let role = match p.role.as_deref() {
None => None,
Some("owner") => Some(sprout_sdk::MemberRole::Owner),
Some("admin") => Some(sprout_sdk::MemberRole::Admin),
Some("member") => Some(sprout_sdk::MemberRole::Member),
Some("guest") => Some(sprout_sdk::MemberRole::Guest),
Some("bot") => Some(sprout_sdk::MemberRole::Bot),
Some(other) => {
return format!(
"Error: invalid role: {other:?} (must be owner/admin/member/guest/bot)"
)
}
};
let builder = match sprout_sdk::build_add_member(channel_uuid, &p.pubkey, role) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign add_member 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}"),
}
}
/// Remove a member from a channel.
#[tool(
name = "remove_channel_member",
description = "Remove a member from a Sprout channel by their public key."
)]
pub async fn remove_channel_member(
&self,
Parameters(p): Parameters<RemoveChannelMemberParams>,
) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_remove_member(channel_uuid, &p.pubkey) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign remove_member 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}"),
}
}
/// List all members of a channel.
#[tool(
name = "list_channel_members",
description = "List all members of a Sprout channel."
)]
pub async fn list_channel_members(
&self,
Parameters(p): Parameters<ListChannelMembersParams>,
) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
// Query membership list event (kind:39002 = NIP-29 group members) for this channel.
let filter = Filter::new()
.kind(k(kind::KIND_NIP29_GROUP_MEMBERS))
.custom_tag(tag_d(), [&p.channel_id])
.limit(1);
match self.client.query(vec![filter]).await {
Ok(events) => match events.first() {
Some(event) => {
// Members are in p-tags: ["p", "<pubkey>", "<role>"]
let members: Vec<serde_json::Value> = event
.tags
.iter()
.filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p"))
.map(|t| {
let s = t.as_slice();
serde_json::json!({
"pubkey": s.get(1).map(|v| v.as_str()).unwrap_or(""),
"role": s.get(2).map(|v| v.as_str()).unwrap_or("member"),
})
})
.collect();
serde_json::to_string(&members).unwrap_or_else(|e| format!("Error: {e}"))
}
None => "[]".to_string(),
},
Err(e) => format!("Error: {e}"),
}
}
/// Join a channel (add yourself as a member).
#[tool(
name = "join_channel",
description = "Join a Sprout channel (adds the agent as a member)."
)]
pub async fn join_channel(&self, Parameters(p): Parameters<JoinChannelParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_join(channel_uuid) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign join 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}"),
}
}
/// Leave a channel (remove yourself as a member).
#[tool(
name = "leave_channel",
description = "Leave a Sprout channel (removes the agent as a member)."
)]
pub async fn leave_channel(&self, Parameters(p): Parameters<LeaveChannelParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_leave(channel_uuid) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign leave 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}"),
}
}
/// Get details for a single channel.
#[tool(
name = "get_channel",
description = "Get metadata and details for a single Sprout channel by ID."
)]
pub async fn get_channel(&self, Parameters(p): Parameters<GetChannelParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
// Query channel metadata (kind:41) with d-tag = channel_id.
let filter = Filter::new()
.kind(k(kind::KIND_CHANNEL_METADATA))
.custom_tag(tag_d(), [&p.channel_id])
.limit(1);
match self.client.query(vec![filter]).await {
Ok(events) => match events.first() {
Some(event) => {
let content: serde_json::Value =
serde_json::from_str(&event.content).unwrap_or(serde_json::json!({}));
serde_json::json!({
"channel_id": p.channel_id,
"name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""),
"description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": event.created_at.as_u64(),
"pubkey": event.pubkey.to_hex(),
})
.to_string()
}
None => format!("Error: channel '{}' not found", p.channel_id),
},
Err(e) => format!("Error: {e}"),
}
}
// ── Metadata tools ────────────────────────────────────────────────────────
/// Update a channel's name and/or description.
#[tool(
name = "update_channel",
description = "Update a Sprout channel's name and/or description."
)]
pub async fn update_channel(&self, Parameters(p): Parameters<UpdateChannelParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_update_channel(
channel_uuid,
p.name.as_deref(),
p.description.as_deref(),
) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign update_channel 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}"),
}
}
/// Set the topic for a channel.
#[tool(
name = "set_channel_topic",
description = "Set the topic for a Sprout channel."
)]
pub async fn set_channel_topic(
&self,
Parameters(p): Parameters<SetChannelTopicParams>,
) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_set_topic(channel_uuid, &p.topic) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign set_topic 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}"),
}
}
/// Set the purpose for a channel.
#[tool(
name = "set_channel_purpose",
description = "Set the purpose for a Sprout channel."
)]
pub async fn set_channel_purpose(
&self,
Parameters(p): Parameters<SetChannelPurposeParams>,
) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_set_purpose(channel_uuid, &p.purpose) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign set_purpose 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}"),
}
}
/// Archive a channel (makes it read-only).
#[tool(
name = "archive_channel",
description = "Archive a Sprout channel, making it read-only."
)]
pub async fn archive_channel(&self, Parameters(p): Parameters<ArchiveChannelParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_archive(channel_uuid) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign archive 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}"),
}
}
/// Unarchive a channel (restores it to active).
#[tool(
name = "unarchive_channel",
description = "Unarchive a Sprout channel, restoring it to active status."
)]
pub async fn unarchive_channel(
&self,
Parameters(p): Parameters<UnarchiveChannelParams>,
) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_unarchive(channel_uuid) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign unarchive 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}"),
}
}
// ── Thread tools ──────────────────────────────────────────────────────────
/// Get a message thread (replies to a message).
#[tool(
name = "get_thread",
description = "Fetch a full thread tree rooted at an event. Returns the root message and all nested \
replies. Works for both stream message threads and forum post threads (kind:45001 root \
with kind:45003 comments)."
)]
pub async fn get_thread(&self, Parameters(p): Parameters<GetThreadParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let limit = p.limit.map(|l| l.min(200) as usize).unwrap_or(100);
// Query events that reference the root event via e-tag.
let filter = Filter::new()
.custom_tag(tag_e(), [&p.event_id])
.custom_tag(tag_h(), [&p.channel_id])
.limit(limit);
// Also fetch the root event itself.
let root_eid = match EventId::from_hex(&p.event_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid event_id: {e}"),
};
let root_filter = Filter::new().id(root_eid).limit(1);
match self.client.query(vec![filter, root_filter]).await {
Ok(mut events) => {
events.sort_by_key(|e| e.created_at);
let result: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.id.to_hex(),
"pubkey": e.pubkey.to_hex(),
"kind": e.kind.as_u16(),
"content": e.content,
"created_at": e.created_at.as_u64(),
"tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
})
})
.collect();
serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
// ── DM tools ──────────────────────────────────────────────────────────────
/// Open or retrieve a direct message channel with one or more participants.
#[tool(
name = "open_dm",
description = "Open (or retrieve an existing) direct message channel with 1–8 other participants. \
Returns the DM channel details including its ID."
)]
pub async fn open_dm(&self, Parameters(p): Parameters<OpenDmParams>) -> String {
if p.pubkeys.is_empty() {
return "Error: pubkeys must contain at least one participant".to_string();
}
if p.pubkeys.len() > 8 {
return format!(
"Error: too many participants (max 8, got {})",
p.pubkeys.len()
);
}
// Command event kind:41010 with p-tags for each participant.
let mut tags: Vec<Tag> = p
.pubkeys
.iter()
.filter_map(|pk| Tag::parse(&["p", pk]).ok())
.collect();
// Add a unique d-tag so the relay can deduplicate.
let dm_id = uuid::Uuid::new_v4().to_string();
tags.push(Tag::parse(&["d", &dm_id]).unwrap());
let builder = EventBuilder::new(k(kind::KIND_DM_OPEN), "", tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign open_dm event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) => serde_json::json!({
"event_id": ok.event_id,
"dm_id": dm_id,
"accepted": ok.accepted,
"message": ok.message,
})
.to_string(),
Err(e) => format!("Error: {e}"),
}
}
/// Add a participant to an existing DM channel.
#[tool(
name = "add_dm_member",
description = "Add a participant to an existing Sprout DM channel."
)]
pub async fn add_dm_member(&self, Parameters(p): Parameters<AddDmMemberParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
// Command event kind:41011 with h-tag = DM channel, p-tag = new member.
let tags = vec![
Tag::parse(&["h", &p.channel_id]).unwrap(),
Tag::parse(&["p", &p.pubkey]).unwrap(),
];
let builder = EventBuilder::new(k(kind::KIND_DM_ADD_MEMBER), "", tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign add_dm_member 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}"),
}
}
/// List all DM channels the agent is a participant in.
#[tool(
name = "list_dms",
description = "List all direct message channels the agent is a participant in."
)]
pub async fn list_dms(&self) -> String {
// Query DM-created events (kind:41001) where we are a participant (p-tag).
let my_pubkey = self.client.pubkey_hex();
let filter = Filter::new()
.kind(k(kind::KIND_DM_CREATED))
.custom_tag(tag_p(), [&my_pubkey])
.limit(100);
match self.client.query(vec![filter]).await {
Ok(events) => {
let dms: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let participants: Vec<&str> = e
.tags
.iter()
.filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p"))
.filter_map(|t| t.as_slice().get(1).map(|v| v.as_str()))
.collect();
let dm_id = e
.tags
.iter()
.find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d"))
.and_then(|t| t.as_slice().get(1))
.map(|s| s.to_string())
.unwrap_or_default();
serde_json::json!({
"dm_id": dm_id,
"participants": participants,
"created_at": e.created_at.as_u64(),
})
})
.collect();
serde_json::to_string(&dms).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
/// Hide a DM channel from the agent's DM list.
#[tool(
name = "hide_dm",
description = "Hide a direct message channel from the agent's DM list. The DM can be restored by opening a new DM with the same participants."
)]
pub async fn hide_dm(&self, Parameters(p): Parameters<HideDmParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
// Command event kind:41012 with h-tag = DM channel.
let tags = vec![Tag::parse(&["h", &p.channel_id]).unwrap()];
let builder = EventBuilder::new(k(kind::KIND_DM_HIDE), "", tags);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign hide_dm event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) if ok.accepted => "DM hidden successfully.".to_string(),
Ok(ok) => format!("Error: relay rejected: {}", ok.message),
Err(e) => format!("Error: {e}"),
}
}
// ── Reaction tools ────────────────────────────────────────────────────────
/// Add an emoji reaction to a message.
#[tool(
name = "add_reaction",
description = "Add an emoji reaction to a Sprout message."
)]
pub async fn add_reaction(&self, Parameters(p): Parameters<AddReactionParams>) -> String {
let target_eid = match EventId::from_hex(&p.event_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid event_id: {e}"),
};
let builder = match sprout_sdk::build_reaction(target_eid, &p.emoji) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign reaction 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}"),
}
}
/// Remove an emoji reaction from a message.
#[tool(
name = "remove_reaction",
description = "Remove an emoji reaction from a Sprout message."
)]
pub async fn remove_reaction(&self, Parameters(p): Parameters<RemoveReactionParams>) -> String {
// Validate event_id format.
if EventId::from_hex(&p.event_id).is_err() {
return format!("Error: invalid event_id: {}", p.event_id);
}
let my_pubkey_parsed = self.client.keys().public_key();
let filter = Filter::new()
.kind(k(kind::KIND_REACTION))
.author(my_pubkey_parsed)
.custom_tag(tag_e(), [&p.event_id])
.limit(50);
let events = match self.client.query(vec![filter]).await {
Ok(e) => e,
Err(e) => return format!("Error: failed to fetch reactions: {e}"),
};
// Find the reaction with matching emoji content.
let reaction_event = events.iter().find(|e| e.content == p.emoji);
match reaction_event {
Some(re) => {
let builder = match sprout_sdk::build_remove_reaction(re.id) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign remove_reaction 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}"),
}
}
None => "Error: could not find your reaction event for this emoji. \
The reaction may not exist."
.to_string(),
}
}
/// Get all reactions for a message.
#[tool(
name = "get_reactions",
description = "Get all emoji reactions for a Sprout message."
)]
pub async fn get_reactions(&self, Parameters(p): Parameters<GetReactionsParams>) -> String {
// Query kind:7 reactions referencing this event via e-tag.
let filter = Filter::new()
.kind(k(kind::KIND_REACTION))
.custom_tag(tag_e(), [&p.event_id])
.limit(200);
match self.client.query(vec![filter]).await {
Ok(events) => {
// Group reactions by emoji content.
let mut grouped: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for e in &events {
grouped
.entry(e.content.clone())
.or_default()
.push(e.pubkey.to_hex());
}
let reactions: Vec<serde_json::Value> = grouped
.into_iter()
.map(|(emoji, pubkeys)| {
serde_json::json!({
"emoji": emoji,
"count": pubkeys.len(),
"pubkeys": pubkeys,
})
})
.collect();
serde_json::to_string(&serde_json::json!({ "reactions": reactions }))
.unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
// ── User profile tools ────────────────────────────────────────────────────
/// Update the agent's user profile.
#[tool(
name = "set_profile",
description = "Update the agent's user profile (display name, about, avatar URL, and/or NIP-05 handle)."
)]
pub async fn set_profile(&self, Parameters(p): Parameters<SetProfileParams>) -> String {
// Read-merge-write: fetch current profile, merge desired changes, sign kind:0.
let my_pubkey = self.client.keys().public_key();
let filter = Filter::new()
.kind(k(kind::KIND_PROFILE))
.author(my_pubkey)
.limit(1);
let current_profile: serde_json::Value = match self.client.query(vec![filter]).await {
Ok(events) => events
.first()
.and_then(|e| serde_json::from_str(&e.content).ok())
.unwrap_or(serde_json::Value::Object(Default::default())),
Err(_) => serde_json::Value::Object(Default::default()),
};
// Resolve each field: use new value if provided, else keep existing.
let display_name = p
.display_name
.as_deref()
.or_else(|| current_profile.get("display_name").and_then(|v| v.as_str()));
let name = current_profile.get("name").and_then(|v| v.as_str());
let picture = p
.avatar_url
.as_deref()
.or_else(|| current_profile.get("avatar_url").and_then(|v| v.as_str()));
let about = p
.about
.as_deref()
.or_else(|| current_profile.get("about").and_then(|v| v.as_str()));
let nip05 = p
.nip05_handle
.as_deref()
.or_else(|| current_profile.get("nip05_handle").and_then(|v| v.as_str()));
let builder = match sprout_sdk::build_profile(display_name, name, picture, about, nip05) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign profile 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}"),
}
}
/// Get user profile(s) by pubkey.
#[tool(
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_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();
}
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
);
}
}
// If no pubkeys specified, fetch our own profile.
let authors: Vec<nostr::PublicKey> = if pubkeys.is_empty() {
vec![self.client.keys().public_key()]
} else {
pubkeys
.iter()
.filter_map(|pk| nostr::PublicKey::from_hex(pk).ok())
.collect()
};
let filter = Filter::new()
.kind(k(kind::KIND_PROFILE))
.authors(authors)
.limit(pubkeys.len().max(1));
match self.client.query(vec![filter]).await {
Ok(events) => {
let profiles: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let mut profile: serde_json::Value =
serde_json::from_str(&e.content).unwrap_or(serde_json::json!({}));
if let Some(obj) = profile.as_object_mut() {
obj.insert("pubkey".to_string(), serde_json::json!(e.pubkey.to_hex()));
}
profile
})
.collect();
if profiles.len() == 1 {
serde_json::to_string(&profiles[0]).unwrap_or_else(|e| format!("Error: {e}"))
} else {
serde_json::to_string(&profiles).unwrap_or_else(|e| format!("Error: {e}"))
}
}
Err(e) => format!("Error fetching profile: {e}"),
}
}
/// Full-text search across messages.
#[tool(
name = "search",
description = "Full-text search across messages in accessible channels. Returns matching messages with channel context. Powered by Typesense."
)]
pub async fn search(&self, Parameters(p): Parameters<SearchParams>) -> String {
let limit = p.limit.unwrap_or(20).min(100) as usize;
// Use NIP-50 search filter if the relay supports it.
// The `search` field in a filter is a NIP-50 extension.
let filter = Filter::new()
.kinds(vec![
k(kind::KIND_STREAM_MESSAGE),
k(kind::KIND_STREAM_MESSAGE_V2),
k(kind::KIND_FORUM_POST),
k(kind::KIND_FORUM_COMMENT),
])
.search(&p.q)
.limit(limit);
match self.client.query(vec![filter]).await {
Ok(events) => {
let results: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.id.to_hex(),
"pubkey": e.pubkey.to_hex(),
"kind": e.kind.as_u16(),
"content": e.content,
"created_at": e.created_at.as_u64(),
"tags": e.tags.iter().map(|t| t.as_slice()).collect::<Vec<_>>(),
})
})
.collect();
serde_json::to_string(&results).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error searching: {e}"),
}
}
/// Get presence status for one or more users.
#[tool(
name = "get_presence",
description = "Get presence status (online/away/offline) for one or more users by pubkey. Pass comma-separated hex pubkeys."
)]
pub async fn get_presence(&self, Parameters(p): Parameters<GetPresenceParams>) -> String {
// Query ephemeral presence events (kind:20001) for the given pubkeys.
let authors: Vec<nostr::PublicKey> = p
.pubkeys
.split(',')
.filter_map(|pk| nostr::PublicKey::from_hex(pk.trim()).ok())
.collect();
if authors.is_empty() {
return "Error: no valid pubkeys provided".to_string();
}
// Presence snapshots are kind:40902 (relay-generated, latest state).
let filter = Filter::new()
.kind(k(kind::KIND_PRESENCE_SNAPSHOT))
.authors(authors)
.limit(200);
match self.client.query(vec![filter]).await {
Ok(events) => {
let presence: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"pubkey": e.pubkey.to_hex(),
"status": e.content,
"updated_at": e.created_at.as_u64(),
})
})
.collect();
serde_json::to_string(&presence).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error fetching presence: {e}"),
}
}
/// Set the agent's presence status.
#[tool(
name = "set_presence",
description = "Set the agent's presence status. Valid values: 'online', 'away', 'offline'. Presence auto-expires after 90 seconds — call periodically to stay online."
)]
pub async fn set_presence(&self, Parameters(p): Parameters<SetPresenceParams>) -> String {
// Validate status value.
// Publish ephemeral presence event (kind:20001).
let builder = EventBuilder::new(k(kind::KIND_PRESENCE_UPDATE), p.status.as_str(), []);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign presence event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) => serde_json::json!({
"status": p.status,
"accepted": ok.accepted,
})
.to_string(),
Err(e) => format!("Error: {e}"),
}
}
/// Set this agent's channel addition policy.
#[tool(
name = "set_channel_add_policy",
description = "Set your channel addition policy. 'anyone' = any authenticated user can add you to open channels (default). 'owner_only' = only your provisioned owner can add you. 'nobody' = no one can add you; you may self-join open channels via join_channel, but private channels are inaccessible until a consent flow is implemented."
)]
pub async fn set_channel_add_policy(
&self,
Parameters(p): Parameters<SetChannelAddPolicyParams>,
) -> String {
if !matches!(p.policy.as_str(), "anyone" | "owner_only" | "nobody") {
return format!(
"Error: invalid policy {:?} — must be 'anyone', 'owner_only', or 'nobody'",
p.policy
);
}
// Store as a kind:10100 (agent profile) replaceable event with the policy in content.
let content = serde_json::json!({ "channel_add_policy": p.policy }).to_string();
let builder = EventBuilder::new(k(kind::KIND_AGENT_PROFILE), &content, []);
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign agent profile event: {e}"),
};
match self.client.send_event(event).await {
Ok(ok) => serde_json::json!({
"policy": p.policy,
"accepted": ok.accepted,
})
.to_string(),
Err(e) => format!("Error: {e}"),
}
}
/// Vote on a forum post or comment (kind:45002).
#[tool(
name = "vote_on_post",
description = "Vote on a forum post or comment. Creates a kind:45002 event. \
Each vote is a separate event — vote deduplication is not yet enforced."
)]
pub async fn vote_on_post(&self, Parameters(p): Parameters<VoteOnPostParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
if p.event_id.len() != 64 || !p.event_id.chars().all(|c| c.is_ascii_hexdigit()) {
return format!(
"Error: event_id must be a 64-character hex string (got {:?})",
p.event_id
);
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let target_eid = match EventId::from_hex(&p.event_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid event_id: {e}"),
};
let direction = match p.direction {
VoteDirection::Up => sprout_sdk::VoteDirection::Up,
VoteDirection::Down => sprout_sdk::VoteDirection::Down,
};
let builder = match sprout_sdk::build_vote(channel_uuid, target_eid, direction) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign vote 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}"),
}
}
/// Permanently delete a Sprout channel.
#[tool(
name = "delete_channel",
description = "Permanently delete a Sprout channel. You must be the channel owner. This action is irreversible."
)]
pub async fn delete_channel(&self, Parameters(p): Parameters<DeleteChannelParams>) -> String {
if let Err(e) = validate_uuid(&p.channel_id) {
return format!("Error: {e}");
}
let channel_uuid = match uuid::Uuid::parse_str(&p.channel_id) {
Ok(u) => u,
Err(_) => return format!("Error: invalid UUID: {}", p.channel_id),
};
let builder = match sprout_sdk::build_delete_channel(channel_uuid) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign delete_channel 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}"),
}
}
// ── Social tools ─────────────────────────────────────────────────────────
/// Publish a kind:1 text note (global, no channel scope).
#[tool(
name = "publish_note",
description = "Publish a short text note (kind:1) to the global feed. Optionally reply to another note by event ID."
)]
pub async fn publish_note(&self, Parameters(p): Parameters<PublishNoteParams>) -> String {
let reply_id = match p.reply_to_event_id.as_deref() {
Some(hex) => match EventId::from_hex(hex) {
Ok(id) => Some(id),
Err(e) => return format!("Error: invalid reply_to_event_id: {e}"),
},
None => None,
};
if p.content.len() > 64 * 1024 {
return format!(
"Error: content exceeds 64 KiB limit ({} bytes)",
p.content.len()
);
}
let builder = match sprout_sdk::build_note(&p.content, reply_id) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign 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}"),
}
}
/// Replace the authenticated user's contact list (kind:3).
#[tool(
name = "set_contact_list",
description = "Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. Call get_contact_list first for delta updates."
)]
pub async fn set_contact_list(
&self,
Parameters(p): Parameters<SetContactListParams>,
) -> String {
let contacts: Vec<(&str, Option<&str>, Option<&str>)> = p
.contacts
.iter()
.map(|c| {
(
c.pubkey.as_str(),
c.relay_url.as_deref(),
c.petname.as_deref(),
)
})
.collect();
let builder = match sprout_sdk::build_contact_list(&contacts) {
Ok(b) => b,
Err(e) => return format!("Error: {e}"),
};
let event = match self.client.sign_event(builder) {
Ok(e) => e,
Err(e) => return format!("Error: failed to sign 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}"),
}
}
/// Fetch a single event by event ID.
#[tool(
name = "get_event",
description = "Fetch a single event by its 64-char hex event ID. For global events: kind:0 profiles and kind:3 contacts require UsersRead scope; kind:1 notes and kind:30023 articles require MessagesRead scope. For channel events: requires MessagesRead scope and channel membership. Unknown kinds return 404."
)]
pub async fn get_event(&self, Parameters(p): Parameters<GetEventParams>) -> String {
if let Err(e) = validate_hex64(&p.event_id, "event_id") {
return e;
}
let eid = match EventId::from_hex(&p.event_id) {
Ok(id) => id,
Err(e) => return format!("Error: invalid event_id: {e}"),
};
let filter = Filter::new().id(eid).limit(1);
match self.client.query(vec![filter]).await {
Ok(events) => match events.first() {
Some(event) => event.as_json(),
None => format!("Error: event '{}' not found", p.event_id),
},
Err(e) => format!("Error: {e}"),
}
}
/// List notes by a specific user.
#[tool(
name = "get_user_notes",
description = "List kind:1 text notes by a specific user (by hex pubkey). Returns id, pubkey, created_at, and content per note (tags and sig omitted — use get_event for full events). Supports composite cursor pagination via `before` (Unix timestamp) and `before_id` (hex event ID)."
)]
pub async fn get_user_notes(&self, Parameters(p): Parameters<GetUserNotesParams>) -> String {
if let Err(e) = validate_hex64(&p.pubkey, "pubkey") {
return e;
}
let author = match nostr::PublicKey::from_hex(&p.pubkey) {
Ok(pk) => pk,
Err(e) => return format!("Error: invalid pubkey: {e}"),
};
let limit = p.limit.unwrap_or(20).min(200) as usize;
let mut filter = Filter::new()
.kind(k(kind::KIND_TEXT_NOTE))
.author(author)
.limit(limit);
if let Some(before) = p.before {
filter = filter.until(nostr::Timestamp::from(before as u64));
}
match self.client.query(vec![filter]).await {
Ok(events) => {
let notes: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"id": e.id.to_hex(),
"pubkey": e.pubkey.to_hex(),
"content": e.content,
"created_at": e.created_at.as_u64(),
})
})
.collect();
serde_json::to_string(&notes).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
/// Get a user's contact/follow list.
#[tool(
name = "get_contact_list",
description = "Get a user's contact/follow list (kind:3) by hex pubkey. Returns the latest replaceable event."
)]
pub async fn get_contact_list(
&self,
Parameters(p): Parameters<GetContactListParams>,
) -> String {
if let Err(e) = validate_hex64(&p.pubkey, "pubkey") {
return e;
}
let author = match nostr::PublicKey::from_hex(&p.pubkey) {
Ok(pk) => pk,
Err(e) => return format!("Error: invalid pubkey: {e}"),
};
// Kind:3 is replaceable — query latest.
let filter = Filter::new()
.kind(k(kind::KIND_CONTACT_LIST))
.author(author)
.limit(1);
match self.client.query(vec![filter]).await {
Ok(events) => match events.first() {
Some(event) => event.as_json(),
None => format!("Error: no contact list found for {}", p.pubkey),
},
Err(e) => format!("Error: {e}"),
}
}
/// Upload a local file to the Sprout relay.
#[tool(
name = "upload_file",
description = "Upload a local file (image or video) to the Sprout relay. \
Returns a BlobDescriptor with the URL, hash, dimensions, and other metadata. \
Supported types: JPEG, PNG, GIF, WebP, MP4. \
The returned URL can be included in messages, or use the file_paths parameter \
on send_message to upload and attach in one step."
)]
pub async fn upload_file(&self, Parameters(p): Parameters<UploadFileParams>) -> String {
match crate::upload::upload_file(
self.client.http_client(),
self.client.keys(),
&self.client.relay_http_url(),
self.client.server_domain().as_deref(),
&p.file_path,
self.client.auth_tag_json().as_deref(),
)
.await
{
Ok(desc) => {
serde_json::to_string_pretty(&desc).unwrap_or_else(|e| format!("Error: {e}"))
}
Err(e) => format!("Error: {e}"),
}
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for SproutMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"sprout-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"Sprout MCP server — interact with the Sprout relay. \
Send messages, read channel history, create channels, \
manage canvases, create and manage workflows, \
and read your personalized home feed."
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── percent_encode ────────────────────────────────────────────────────────
#[test]
fn percent_encode_empty_string() {
assert_eq!(percent_encode(""), "");
}
#[test]
fn percent_encode_already_safe_chars() {
// Unreserved chars (RFC 3986) must pass through unchanged.
let safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
assert_eq!(percent_encode(safe), safe);
}
#[test]
fn percent_encode_space() {
assert_eq!(percent_encode(" "), "%20");
}
#[test]
fn percent_encode_special_chars() {
assert_eq!(percent_encode("hello world"), "hello%20world");
assert_eq!(percent_encode("a&b=c"), "a%26b%3Dc");
assert_eq!(percent_encode("foo?bar"), "foo%3Fbar");
}
#[test]
fn percent_encode_slash() {
assert_eq!(percent_encode("/"), "%2F");
}
#[test]
fn percent_encode_unicode_multibyte() {
// "é" is 0xC3 0xA9 in UTF-8.
assert_eq!(percent_encode("é"), "%C3%A9");
}
// ── validate_uuid ─────────────────────────────────────────────────────────
#[test]
fn validate_uuid_valid() {
assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
}
#[test]
fn validate_uuid_valid_v4() {
assert!(validate_uuid("f47ac10b-58cc-4372-a567-0e02b2c3d479").is_ok());
}
#[test]
fn validate_uuid_invalid_string() {
let result = validate_uuid("not-a-uuid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid UUID"));
}
#[test]
fn validate_uuid_empty_string() {
let result = validate_uuid("");
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid UUID"));
}
#[test]
fn validate_uuid_almost_valid() {
// Missing one character in the last group.
let result = validate_uuid("550e8400-e29b-41d4-a716-44665544000");
assert!(result.is_err());
}
// ── MAX_CONTENT_BYTES ─────────────────────────────────────────────────────
#[test]
fn max_content_bytes_value() {
assert_eq!(MAX_CONTENT_BYTES, 65_536);
}
// ── VoteDirection serde ───────────────────────────────────────────────────
#[test]
fn vote_direction_serializes_lowercase() {
assert_eq!(serde_json::to_string(&VoteDirection::Up).unwrap(), "\"up\"");
assert_eq!(
serde_json::to_string(&VoteDirection::Down).unwrap(),
"\"down\""
);
}
#[test]
fn vote_direction_deserializes_lowercase() {
assert!(matches!(
serde_json::from_str::<VoteDirection>("\"up\"").unwrap(),
VoteDirection::Up
));
assert!(matches!(
serde_json::from_str::<VoteDirection>("\"down\"").unwrap(),
VoteDirection::Down
));
}
#[test]
fn vote_direction_rejects_invalid() {
assert!(serde_json::from_str::<VoteDirection>("\"sideways\"").is_err());
assert!(serde_json::from_str::<VoteDirection>("\"UP\"").is_err());
assert!(serde_json::from_str::<VoteDirection>("\"\"").is_err());
}
#[test]
fn vote_on_post_params_round_trip() {
let params = VoteOnPostParams {
channel_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
event_id: "a".repeat(64),
direction: VoteDirection::Up,
};
let json = serde_json::to_string(&params).unwrap();
let parsed: VoteOnPostParams = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.channel_id, params.channel_id);
assert_eq!(parsed.event_id, params.event_id);
assert!(matches!(parsed.direction, VoteDirection::Up));
}
// Note: `extract_at_names` is now exercised by `sprout-sdk::mentions`
// tests. The MCP-side I/O wrapper around it (`resolve_content_mentions`)
// is covered by integration tests against a running relay.
}
#[cfg(test)]
mod diff_tests {
use super::*;
#[test]
fn truncate_diff_small_passes_through() {
let diff = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
let (result, truncated) = truncate_diff(diff, 60 * 1024);
assert_eq!(result, diff);
assert!(!truncated);
}
#[test]
fn truncate_diff_cuts_at_hunk_boundary() {
// Build a diff large enough that truncation is meaningful.
// Repeat the first hunk many times so the total is well above any
// reasonable max_bytes, then append a second hunk we want excluded.
let hunk_unit = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
let mut diff = hunk_unit.repeat(20); // ~1140 bytes of first-hunk content
diff.push_str("@@ -10,3 +10,3 @@\n more context\n-old2\n+new2\n");
// max_bytes sits inside the repeated first-hunk region (well below total)
// but above TRUNCATION_NOTICE.len() so effective_limit > 0.
// effective_limit = max_bytes - TRUNCATION_NOTICE.len() ≈ 500 - 72 = 428,
// which lands inside the repeated first-hunk block.
let max_bytes = 500;
let (result, truncated) = truncate_diff(&diff, max_bytes);
assert!(truncated);
assert!(
result.contains("context"),
"should contain first-hunk content"
);
assert!(result.contains("Diff truncated"));
assert!(
!result.contains("@@ -10,3"),
"second hunk should be excluded"
);
// Result must not exceed max_bytes.
assert!(
result.len() <= max_bytes,
"truncated result ({}) exceeds max_bytes ({})",
result.len(),
max_bytes
);
}
#[test]
fn truncate_diff_utf8_safe() {
// Create a diff with multi-byte chars near the boundary
let mut diff = String::from("--- a/file\n+++ b/file\n@@ -1,1 +1,1 @@\n-");
// Add enough content to exceed a small limit, with multi-byte chars
for _ in 0..100 {
diff.push('日'); // 3-byte UTF-8 char
}
diff.push('\n');
let (result, truncated) = truncate_diff(&diff, 80);
assert!(truncated);
// Must not panic and must produce valid UTF-8
assert!(result.is_char_boundary(result.len()));
}
#[test]
fn truncate_diff_result_within_limit() {
let mut diff = String::new();
for i in 0..2000 {
diff.push_str(&format!(
"@@ -{i},1 +{i},1 @@\n-old line {i}\n+new line {i}\n"
));
}
let max = 1024;
let (result, truncated) = truncate_diff(&diff, max);
assert!(truncated);
assert!(
result.len() <= max,
"truncated result ({}) exceeds max_bytes ({})",
result.len(),
max
);
}
#[test]
fn infer_language_known_extensions() {
assert_eq!(infer_language("src/main.rs"), Some("rust".to_string()));
assert_eq!(infer_language("app.tsx"), Some("typescript".to_string()));
assert_eq!(infer_language("script.py"), Some("python".to_string()));
assert_eq!(infer_language("Makefile"), None);
}
#[test]
fn infer_language_no_extension() {
assert_eq!(infer_language("Dockerfile"), None);
// But "foo.dockerfile" should match
assert_eq!(
infer_language("foo.dockerfile"),
Some("dockerfile".to_string())
);
}
}