mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: Image & video uploads for MCP and CLI (Blossom protocol) (#433)
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -3716,6 +3716,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"clap",
|
||||
"hex",
|
||||
"infer",
|
||||
"nostr",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
@@ -3725,6 +3727,7 @@ dependencies = [
|
||||
"sprout-sdk",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3770,7 +3773,10 @@ name = "sprout-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"infer",
|
||||
"nostr",
|
||||
"reqwest 0.13.2",
|
||||
"rmcp",
|
||||
@@ -3778,6 +3784,7 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sprout-core",
|
||||
"sprout-sdk",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -40,8 +40,17 @@ sprout-sdk = { workspace = true }
|
||||
# Base64 encoding — NIP-98 event serialization for Authorization header
|
||||
base64 = "0.22"
|
||||
|
||||
# SHA-256 — NIP-98 payload hash tag
|
||||
# SHA-256 — NIP-98 payload hash tag and Blossom file hash
|
||||
sha2 = "0.11"
|
||||
|
||||
# Hex encoding — SHA-256 hash output for Blossom uploads
|
||||
hex = { workspace = true }
|
||||
|
||||
# MIME type detection via magic bytes — file upload validation
|
||||
infer = "0.19"
|
||||
|
||||
# URL parsing — extract server domain for Blossom auth tag
|
||||
url = { workspace = true }
|
||||
|
||||
# Persona pack parsing, validation, and resolution
|
||||
sprout-persona = { path = "../sprout-persona" }
|
||||
|
||||
@@ -4,6 +4,77 @@ use nostr::Keys;
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blob / Media types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Descriptor returned by the relay after a successful upload.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BlobDescriptor {
|
||||
/// Public URL of the uploaded blob.
|
||||
pub url: String,
|
||||
/// Hex-encoded SHA-256 of the file content.
|
||||
pub sha256: String,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// MIME type (e.g. `image/jpeg`).
|
||||
#[serde(rename = "type")]
|
||||
pub mime_type: String,
|
||||
/// Unix timestamp when the file was uploaded.
|
||||
pub uploaded: i64,
|
||||
/// Image dimensions as `<width>x<height>` (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dim: Option<String>,
|
||||
/// Blurhash placeholder string (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blurhash: Option<String>,
|
||||
/// Thumbnail URL (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb: Option<String>,
|
||||
/// Duration in seconds for video/audio (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<f64>,
|
||||
}
|
||||
|
||||
/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata).
|
||||
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
|
||||
let mut tag = vec![
|
||||
"imeta".to_string(),
|
||||
format!("url {}", d.url),
|
||||
format!("m {}", d.mime_type),
|
||||
format!("x {}", d.sha256),
|
||||
format!("size {}", d.size),
|
||||
];
|
||||
if let Some(ref dim) = d.dim {
|
||||
tag.push(format!("dim {dim}"));
|
||||
}
|
||||
if let Some(ref bh) = d.blurhash {
|
||||
tag.push(format!("blurhash {bh}"));
|
||||
}
|
||||
if let Some(ref th) = d.thumb {
|
||||
tag.push(format!("thumb {th}"));
|
||||
}
|
||||
if let Some(dur) = d.duration {
|
||||
tag.push(format!("duration {dur}"));
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
/// MIME types accepted for upload.
|
||||
const ALLOWED_MIMES: &[&str] = &[
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
];
|
||||
|
||||
/// Maximum file size for image uploads (50 MB).
|
||||
const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Maximum file size for video uploads (500 MB).
|
||||
const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,6 +206,127 @@ impl SproutClient {
|
||||
Ok(resp.text().await?)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// File upload (Blossom protocol)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Upload a file to the relay's Blossom endpoint.
|
||||
/// Returns a BlobDescriptor on success.
|
||||
pub async fn upload_file(&self, file_path: &str) -> Result<BlobDescriptor, CliError> {
|
||||
let keys = self.keys().ok_or_else(|| {
|
||||
CliError::Key("private key required for uploads (set SPROUT_PRIVATE_KEY)".into())
|
||||
})?;
|
||||
|
||||
// 1. Read file — validate it exists and is a regular file
|
||||
let metadata = std::fs::metadata(file_path)
|
||||
.map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(CliError::Usage(format!("{file_path} is not a file")));
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(file_path)
|
||||
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;
|
||||
|
||||
// 2. Detect MIME from magic bytes
|
||||
let mime = infer::get(&bytes)
|
||||
.map(|t| t.mime_type().to_string())
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
if !ALLOWED_MIMES.contains(&mime.as_str()) {
|
||||
return Err(CliError::Usage(format!("unsupported file type: {mime}")));
|
||||
}
|
||||
|
||||
// 3. Size check
|
||||
let max = if mime.starts_with("video/") {
|
||||
MAX_VIDEO_BYTES
|
||||
} else {
|
||||
MAX_IMAGE_BYTES
|
||||
};
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(CliError::Usage(format!(
|
||||
"file too large: {} bytes (max {})",
|
||||
bytes.len(),
|
||||
max
|
||||
)));
|
||||
}
|
||||
|
||||
// 4. SHA-256
|
||||
use sha2::{Digest, Sha256};
|
||||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||||
|
||||
// 5. Sign Blossom auth event (kind:24242)
|
||||
use nostr::{EventBuilder, Kind, Tag, Timestamp};
|
||||
let now = Timestamp::now().as_u64();
|
||||
let expiry = if mime.starts_with("video/") {
|
||||
3600
|
||||
} else {
|
||||
600
|
||||
};
|
||||
let exp_str = (now + expiry).to_string();
|
||||
|
||||
let mut tags = vec![
|
||||
Tag::parse(&["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?,
|
||||
Tag::parse(&["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?,
|
||||
Tag::parse(&["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?,
|
||||
];
|
||||
// Extract server domain from relay URL for BUD-11 server tag
|
||||
if let Ok(parsed) = url::Url::parse(&self.relay_url) {
|
||||
if let Some(host) = parsed.host_str() {
|
||||
let domain = match parsed.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
tags.push(
|
||||
Tag::parse(&["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let auth_event = EventBuilder::new(Kind::from(24242), "Upload file", tags)
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| CliError::Other(format!("signing failed: {e}")))?;
|
||||
|
||||
// 6. Base64url encode the auth event for the header
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use nostr::JsonUtil;
|
||||
let auth_header = format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
|
||||
);
|
||||
|
||||
// 7. PUT request to /media/upload — with generous per-request timeout.
|
||||
// The shared client has a 10s timeout for REST calls; uploads need more.
|
||||
let upload_timeout = if mime.starts_with("video/") {
|
||||
std::time::Duration::from_secs(600)
|
||||
} else {
|
||||
std::time::Duration::from_secs(120)
|
||||
};
|
||||
let url = format!("{}/media/upload", self.relay_url);
|
||||
let mut req = self
|
||||
.http
|
||||
.put(&url)
|
||||
.timeout(upload_timeout)
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Type", &mime)
|
||||
.header("X-SHA-256", &sha256);
|
||||
// Add bearer token as X-Auth-Token for relay auth
|
||||
if let Auth::Bearer(ref token) = self.auth {
|
||||
req = req.header("X-Auth-Token", token.as_str());
|
||||
}
|
||||
|
||||
let resp = req.body(bytes).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(CliError::Relay { status, body });
|
||||
}
|
||||
|
||||
resp.json::<BlobDescriptor>()
|
||||
.await
|
||||
.map_err(|e| CliError::Other(format!("invalid upload response: {e}")))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Convenience wrappers — print response to stdout
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -159,29 +159,55 @@ pub async fn cmd_search(
|
||||
// Write commands — signed events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn cmd_send_message(
|
||||
client: &SproutClient,
|
||||
channel_id: &str,
|
||||
content: &str,
|
||||
_kind: Option<u16>,
|
||||
reply_to: Option<&str>,
|
||||
broadcast: bool,
|
||||
mentions: &[String],
|
||||
) -> Result<(), CliError> {
|
||||
validate_uuid(channel_id)?;
|
||||
validate_content_size(content)?;
|
||||
if let Some(r) = reply_to {
|
||||
pub struct SendMessageParams {
|
||||
pub channel_id: String,
|
||||
pub content: String,
|
||||
#[allow(dead_code)] // reserved for future kind routing
|
||||
pub kind: Option<u16>,
|
||||
pub reply_to: Option<String>,
|
||||
pub broadcast: bool,
|
||||
pub mentions: Vec<String>,
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> {
|
||||
validate_uuid(&p.channel_id)?;
|
||||
validate_content_size(&p.content)?;
|
||||
if let Some(ref r) = p.reply_to {
|
||||
validate_hex64(r)?;
|
||||
}
|
||||
for m in mentions {
|
||||
for m in &p.mentions {
|
||||
validate_hex64(m)?;
|
||||
}
|
||||
|
||||
let keys = require_keys!(client);
|
||||
let channel_uuid = parse_uuid(channel_id)?;
|
||||
let channel_uuid = parse_uuid(&p.channel_id)?;
|
||||
|
||||
// Upload files and build imeta tags
|
||||
let mut media_tags: Vec<Vec<String>> = Vec::new();
|
||||
let mut media_content = String::new();
|
||||
for file_path in &p.files {
|
||||
let desc = client
|
||||
.upload_file(file_path)
|
||||
.await
|
||||
.map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?;
|
||||
media_tags.push(crate::client::build_imeta_tag(&desc));
|
||||
if desc.mime_type.starts_with("video/") {
|
||||
media_content.push_str("\n;
|
||||
} else {
|
||||
media_content.push_str("\n;
|
||||
}
|
||||
media_content.push_str(&desc.url);
|
||||
media_content.push(')');
|
||||
}
|
||||
let final_content = if media_content.is_empty() {
|
||||
p.content.clone()
|
||||
} else {
|
||||
format!("{}{media_content}", p.content)
|
||||
};
|
||||
|
||||
// Build thread ref if replying
|
||||
let thread_ref = if let Some(r) = reply_to {
|
||||
let thread_ref = if let Some(ref r) = p.reply_to {
|
||||
let eid = parse_event_id(r)?;
|
||||
Some(ThreadRef {
|
||||
root_event_id: eid,
|
||||
@@ -192,18 +218,18 @@ pub async fn cmd_send_message(
|
||||
};
|
||||
|
||||
// Normalize explicit mentions, then merge auto-resolved up to SDK cap of 50.
|
||||
let mut merged: Vec<String> = normalize_mention_pubkeys(mentions, "");
|
||||
let auto_resolved = resolve_content_mentions(client, channel_id, content).await;
|
||||
let mut merged: Vec<String> = normalize_mention_pubkeys(&p.mentions, "");
|
||||
let auto_resolved = resolve_content_mentions(client, &p.channel_id, &final_content).await;
|
||||
merge_mentions(&mut merged, &auto_resolved, 50);
|
||||
let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let builder = sprout_sdk::build_message(
|
||||
channel_uuid,
|
||||
content,
|
||||
&final_content,
|
||||
thread_ref.as_ref(),
|
||||
&mention_refs,
|
||||
broadcast,
|
||||
&[],
|
||||
p.broadcast,
|
||||
&media_tags,
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?;
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ enum Cmd {
|
||||
broadcast: bool,
|
||||
#[arg(long = "mention")]
|
||||
mentions: Vec<String>,
|
||||
/// Attach file(s) — uploads and includes as imeta tags
|
||||
#[arg(long = "file")]
|
||||
files: Vec<String>,
|
||||
},
|
||||
/// Send a diff/code-review message
|
||||
SendDiffMessage {
|
||||
@@ -140,6 +143,13 @@ enum Cmd {
|
||||
direction: String,
|
||||
},
|
||||
|
||||
/// Upload a file to the relay (returns BlobDescriptor JSON)
|
||||
UploadFile {
|
||||
/// Path to the file to upload
|
||||
#[arg(long)]
|
||||
file: String,
|
||||
},
|
||||
|
||||
// ---- Channels ----------------------------------------------------------
|
||||
/// List channels
|
||||
ListChannels {
|
||||
@@ -565,15 +575,19 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
reply_to,
|
||||
broadcast,
|
||||
mentions,
|
||||
files,
|
||||
} => {
|
||||
commands::messages::cmd_send_message(
|
||||
&client,
|
||||
&channel,
|
||||
&content,
|
||||
kind,
|
||||
reply_to.as_deref(),
|
||||
broadcast,
|
||||
&mentions,
|
||||
commands::messages::SendMessageParams {
|
||||
channel_id: channel,
|
||||
content,
|
||||
kind,
|
||||
reply_to,
|
||||
broadcast,
|
||||
mentions,
|
||||
files,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -656,6 +670,14 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
Cmd::VoteOnPost { event, direction } => {
|
||||
commands::messages::cmd_vote_on_post(&client, &event, &direction).await
|
||||
}
|
||||
Cmd::UploadFile { file } => {
|
||||
let desc = client.upload_file(&file).await?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Channels ------------------------------------------------------
|
||||
Cmd::ListChannels { visibility, member } => {
|
||||
@@ -893,10 +915,10 @@ mod tests {
|
||||
assert!(super::parse_bool_flag("--approved", "").is_err());
|
||||
}
|
||||
|
||||
/// Parity: the CLI exposes exactly the expected 54 commands.
|
||||
/// Parity: the CLI exposes exactly the expected 55 commands.
|
||||
/// If a command is added or removed, this test forces a conscious update.
|
||||
#[test]
|
||||
fn command_inventory_is_54() {
|
||||
fn command_inventory_is_55() {
|
||||
let expected: Vec<&str> = vec![
|
||||
"add-channel-member",
|
||||
"add-dm-member",
|
||||
@@ -951,6 +973,7 @@ mod tests {
|
||||
"unarchive-channel",
|
||||
"update-channel",
|
||||
"update-workflow",
|
||||
"upload-file",
|
||||
"vote-on-post",
|
||||
];
|
||||
|
||||
@@ -964,8 +987,8 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
actual.len(),
|
||||
54,
|
||||
"Expected 54 commands, got {}. Actual: {:?}",
|
||||
55,
|
||||
"Expected 55 commands, got {}. Actual: {:?}",
|
||||
actual.len(),
|
||||
actual
|
||||
);
|
||||
|
||||
@@ -38,6 +38,12 @@ reqwest = { workspace = true }
|
||||
# TLS crypto provider (required for wss:// connections)
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
# Crypto
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
base64 = "0.22"
|
||||
infer = "0.19"
|
||||
|
||||
# Utilities
|
||||
uuid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -125,3 +125,5 @@ pub mod relay_client;
|
||||
pub mod server;
|
||||
/// Toolset definitions and configuration for organizing MCP tools.
|
||||
pub mod toolsets;
|
||||
/// File upload to the Sprout relay (Blossom protocol).
|
||||
pub mod upload;
|
||||
|
||||
@@ -796,6 +796,43 @@ impl RelayClient {
|
||||
self.keys.public_key().to_hex()
|
||||
}
|
||||
|
||||
/// Returns a reference to the shared reqwest HTTP client.
|
||||
pub fn http_client(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
}
|
||||
|
||||
/// Returns a reference to the Nostr signing keys.
|
||||
pub fn keys(&self) -> &nostr::Keys {
|
||||
&self.keys
|
||||
}
|
||||
|
||||
/// Returns the API token, if configured.
|
||||
pub fn api_token(&self) -> Option<&str> {
|
||||
self.api_token.as_deref()
|
||||
}
|
||||
|
||||
/// Returns the relay's server authority (host or host:port) for BUD-11 server tags.
|
||||
///
|
||||
/// Uses the same logic as the desktop client's `extract_server_authority`:
|
||||
/// default ports (80/443) are omitted, non-default ports are included.
|
||||
/// Returns `None` for localhost (no server tag in dev mode).
|
||||
pub fn server_domain(&self) -> Option<String> {
|
||||
// Convert ws:// → http://, wss:// → https:// for url::Url parsing.
|
||||
let http_url = self
|
||||
.relay_url
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://");
|
||||
let parsed = url::Url::parse(&http_url).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
if host.is_empty() || host == "localhost" {
|
||||
return None;
|
||||
}
|
||||
match parsed.port() {
|
||||
Some(port) => Some(format!("{host}:{port}")),
|
||||
None => Some(host.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate auth header for REST requests.
|
||||
///
|
||||
/// - If an API token is present: `Authorization: Bearer <token>` (production mode).
|
||||
|
||||
@@ -178,6 +178,10 @@ pub struct SendMessageParams {
|
||||
/// 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)
|
||||
@@ -805,6 +809,13 @@ fn infer_language(file_path: &str) -> Option<String> {
|
||||
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 {
|
||||
@@ -938,11 +949,53 @@ Default kind is 9 (stream message)."
|
||||
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.api_token(),
|
||||
self.client.server_domain().as_deref(),
|
||||
path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(desc) => {
|
||||
media_tags.push(crate::upload::build_imeta_tag(&desc));
|
||||
if desc.mime_type.starts_with("video/") {
|
||||
media_content.push_str("\n;
|
||||
} else {
|
||||
media_content.push_str("\n;
|
||||
}
|
||||
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, &p.content, &mention_refs, &[]) {
|
||||
match sprout_sdk::build_forum_post(
|
||||
channel_uuid,
|
||||
&final_content,
|
||||
&mention_refs,
|
||||
&media_tags,
|
||||
) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
}
|
||||
@@ -964,10 +1017,10 @@ Default kind is 9 (stream message)."
|
||||
};
|
||||
match sprout_sdk::build_forum_comment(
|
||||
channel_uuid,
|
||||
&p.content,
|
||||
&final_content,
|
||||
&thread_ref,
|
||||
&mention_refs,
|
||||
&[],
|
||||
&media_tags,
|
||||
) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
@@ -989,11 +1042,11 @@ Default kind is 9 (stream message)."
|
||||
};
|
||||
match sprout_sdk::build_message(
|
||||
channel_uuid,
|
||||
&p.content,
|
||||
&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}"),
|
||||
@@ -2620,6 +2673,33 @@ with kind:45003 comments)."
|
||||
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.api_token(),
|
||||
self.client.server_domain().as_deref(),
|
||||
&p.file_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(desc) => {
|
||||
serde_json::to_string_pretty(&desc).unwrap_or_else(|e| format!("Error: {e}"))
|
||||
}
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
|
||||
@@ -103,13 +103,13 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[
|
||||
("get_event", "social", true),
|
||||
("get_user_notes", "social", true),
|
||||
("get_contact_list", "social", true),
|
||||
// Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe
|
||||
// ── media ────────────────────────────────────────────────────────────────
|
||||
("upload_file", "media", false),
|
||||
];
|
||||
|
||||
/// Tools planned but not yet implemented. These will be added to ALL_TOOLS
|
||||
/// when their #[tool] handlers are created in server.rs.
|
||||
pub const DEFERRED_TOOLS: &[(&str, &str, bool)] = &[
|
||||
("upload_file", "media", false),
|
||||
("subscribe", "realtime", true),
|
||||
("unsubscribe", "realtime", false),
|
||||
];
|
||||
@@ -383,13 +383,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_count_is_48() {
|
||||
assert_eq!(ALL_TOOLS.len(), 48);
|
||||
fn all_tools_count_is_49() {
|
||||
assert_eq!(ALL_TOOLS.len(), 49);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_tools_count_is_3() {
|
||||
assert_eq!(DEFERRED_TOOLS.len(), 3);
|
||||
fn deferred_tools_count_is_2() {
|
||||
assert_eq!(DEFERRED_TOOLS.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -408,15 +408,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_toolsets_returns_correct_count() {
|
||||
// ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social
|
||||
// (media and realtime have no implemented tools yet)
|
||||
// ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social, media
|
||||
// (realtime has no implemented tools yet)
|
||||
let defs = all_toolsets();
|
||||
assert_eq!(defs.len(), 8);
|
||||
assert_eq!(defs.len(), 9);
|
||||
let names: Vec<_> = defs.iter().map(|d| d.name).collect();
|
||||
assert!(names.contains(&"default"));
|
||||
assert!(names.contains(&"canvas"));
|
||||
assert!(names.contains(&"forums"));
|
||||
assert!(names.contains(&"social"));
|
||||
assert!(names.contains(&"media"));
|
||||
}
|
||||
|
||||
// ── Cross-check: ALL_TOOLS integrity ────────────────────────────────────
|
||||
|
||||
419
crates/sprout-mcp/src/upload.rs
Normal file
419
crates/sprout-mcp/src/upload.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
//! File upload pipeline for Sprout media (Blossom protocol).
|
||||
//!
|
||||
//! Reads a local file, validates it against size/type constraints, computes a SHA-256
|
||||
//! hash, signs a kind:24242 Blossom auth event, PUTs the file to the relay's
|
||||
//! `/media/upload` endpoint, and returns a [`BlobDescriptor`].
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::util::hex as nostr_hex;
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum file size for image uploads (50 MB).
|
||||
pub const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Maximum file size for video uploads (500 MB).
|
||||
pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024;
|
||||
|
||||
/// MIME types we accept for upload.
|
||||
const ALLOWED_MIMES: &[&str] = &[
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
];
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Descriptor returned by the relay after a successful upload.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BlobDescriptor {
|
||||
/// Public URL of the uploaded blob.
|
||||
pub url: String,
|
||||
/// Hex-encoded SHA-256 of the file content.
|
||||
pub sha256: String,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// MIME type (e.g. `image/jpeg`).
|
||||
#[serde(rename = "type")]
|
||||
pub mime_type: String,
|
||||
/// Unix timestamp when the file was uploaded.
|
||||
pub uploaded: i64,
|
||||
/// Image dimensions as `<width>x<height>` (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub dim: Option<String>,
|
||||
/// Blurhash placeholder string (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blurhash: Option<String>,
|
||||
/// Thumbnail URL (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumb: Option<String>,
|
||||
/// Duration in seconds for video/audio (optional).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<f64>,
|
||||
}
|
||||
|
||||
/// Errors that can occur during the upload pipeline.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UploadError {
|
||||
/// The path does not exist on disk.
|
||||
#[error("file not found: {0}")]
|
||||
FileNotFound(String),
|
||||
/// The path exists but is not a regular file.
|
||||
#[error("not a file: {0}")]
|
||||
NotAFile(String),
|
||||
/// File exceeds the size limit for its type.
|
||||
#[error("file too large: {size} bytes (max {max})")]
|
||||
FileTooLarge {
|
||||
/// Actual file size.
|
||||
size: u64,
|
||||
/// Maximum allowed size.
|
||||
max: u64,
|
||||
},
|
||||
/// MIME type is not in the allowlist.
|
||||
#[error("unsupported file type: {0}")]
|
||||
UnsupportedFileType(String),
|
||||
/// Read bytes don't match metadata length.
|
||||
#[error("size mismatch: metadata says {expected}, read {actual}")]
|
||||
SizeMismatch {
|
||||
/// Size reported by filesystem metadata.
|
||||
expected: u64,
|
||||
/// Actual number of bytes read.
|
||||
actual: u64,
|
||||
},
|
||||
/// Filesystem I/O error.
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// Nostr event signing failed.
|
||||
#[error("signing failed: {0}")]
|
||||
SigningFailed(String),
|
||||
/// Server returned a non-success status.
|
||||
#[error("upload rejected ({status}): {body}")]
|
||||
ServerRejected {
|
||||
/// HTTP status code.
|
||||
status: u16,
|
||||
/// Response body text.
|
||||
body: String,
|
||||
},
|
||||
/// HTTP transport error.
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
/// Could not parse the server's response as a BlobDescriptor.
|
||||
#[error("invalid response: {0}")]
|
||||
InvalidResponse(String),
|
||||
}
|
||||
|
||||
// ── Upload pipeline ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Upload a local file to the Sprout relay's media endpoint.
|
||||
///
|
||||
/// Performs validation, SHA-256 hashing, Blossom auth signing, and the HTTP PUT.
|
||||
/// Returns the relay's [`BlobDescriptor`] on success.
|
||||
pub async fn upload_file(
|
||||
http: &reqwest::Client,
|
||||
keys: &Keys,
|
||||
relay_http_url: &str,
|
||||
api_token: Option<&str>,
|
||||
server_domain: Option<&str>,
|
||||
file_path: &str,
|
||||
) -> Result<BlobDescriptor, UploadError> {
|
||||
// 1. Validate path exists
|
||||
let metadata = std::fs::metadata(file_path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
UploadError::FileNotFound(file_path.to_string())
|
||||
} else {
|
||||
UploadError::Io(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
if !metadata.is_file() {
|
||||
return Err(UploadError::NotAFile(file_path.to_string()));
|
||||
}
|
||||
|
||||
let expected_size = metadata.len();
|
||||
|
||||
// Early rejection: no supported file type exceeds MAX_VIDEO_BYTES.
|
||||
// This prevents buffering hundreds of MB into RAM before MIME detection.
|
||||
if expected_size > MAX_VIDEO_BYTES {
|
||||
return Err(UploadError::FileTooLarge {
|
||||
size: expected_size,
|
||||
max: MAX_VIDEO_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Read file into memory
|
||||
let bytes = std::fs::read(file_path)?;
|
||||
let actual_size = bytes.len() as u64;
|
||||
|
||||
// Post-read size check
|
||||
if actual_size != expected_size {
|
||||
return Err(UploadError::SizeMismatch {
|
||||
expected: expected_size,
|
||||
actual: actual_size,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Detect MIME via magic bytes
|
||||
let mime = infer::get(&bytes)
|
||||
.map(|t| t.mime_type())
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
if !ALLOWED_MIMES.contains(&mime) {
|
||||
return Err(UploadError::UnsupportedFileType(mime.to_string()));
|
||||
}
|
||||
|
||||
// 4. Pre-check file size against type-specific limits
|
||||
let max_size = if mime.starts_with("video/") {
|
||||
MAX_VIDEO_BYTES
|
||||
} else {
|
||||
MAX_IMAGE_BYTES
|
||||
};
|
||||
|
||||
if actual_size > max_size {
|
||||
return Err(UploadError::FileTooLarge {
|
||||
size: actual_size,
|
||||
max: max_size,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Compute SHA-256
|
||||
let sha256 = nostr_hex::encode(Sha256::digest(&bytes));
|
||||
|
||||
// 6. Sign Blossom auth event (kind:24242)
|
||||
let now = Timestamp::now().as_u64();
|
||||
let expiry = if mime.starts_with("video/") {
|
||||
3600
|
||||
} else {
|
||||
600
|
||||
};
|
||||
let exp_str = (now + expiry).to_string();
|
||||
|
||||
let mut tags = vec![
|
||||
Tag::parse(&["t", "upload"]).map_err(|e| UploadError::SigningFailed(e.to_string()))?,
|
||||
Tag::parse(&["x", &sha256]).map_err(|e| UploadError::SigningFailed(e.to_string()))?,
|
||||
Tag::parse(&["expiration", &exp_str])
|
||||
.map_err(|e| UploadError::SigningFailed(e.to_string()))?,
|
||||
];
|
||||
if let Some(domain) = server_domain {
|
||||
tags.push(
|
||||
Tag::parse(&["server", domain])
|
||||
.map_err(|e| UploadError::SigningFailed(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
|
||||
let auth_event = EventBuilder::new(Kind::from(24242), "Upload file", tags)
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| UploadError::SigningFailed(e.to_string()))?;
|
||||
|
||||
// 7. Base64url encode the auth event
|
||||
let auth_header = format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
|
||||
);
|
||||
|
||||
// 8. HTTP PUT — with a generous per-request timeout.
|
||||
// The shared reqwest client has a 10s timeout suitable for REST API calls,
|
||||
// but uploads can take minutes for large files. Override per-request.
|
||||
let upload_timeout = if mime.starts_with("video/") {
|
||||
std::time::Duration::from_secs(600) // 10 min for video (up to 500 MB)
|
||||
} else {
|
||||
std::time::Duration::from_secs(120) // 2 min for images (up to 50 MB)
|
||||
};
|
||||
|
||||
let url = format!("{}/media/upload", relay_http_url.trim_end_matches('/'));
|
||||
let mut req = http
|
||||
.put(&url)
|
||||
.timeout(upload_timeout)
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Type", mime)
|
||||
.header("X-SHA-256", &sha256);
|
||||
|
||||
if let Some(token) = api_token {
|
||||
req = req.header("X-Auth-Token", token);
|
||||
}
|
||||
|
||||
let resp = req.body(bytes).send().await?;
|
||||
|
||||
// 9. Handle response
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(UploadError::ServerRejected {
|
||||
status: status.as_u16(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
let body = resp.text().await?;
|
||||
serde_json::from_str::<BlobDescriptor>(&body)
|
||||
.map_err(|e| UploadError::InvalidResponse(format!("{e}: {body}")))
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a NIP-92 `imeta` tag from a [`BlobDescriptor`].
|
||||
///
|
||||
/// The returned `Vec<String>` is suitable for passing to `Tag::parse`.
|
||||
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
|
||||
let mut tag = vec![
|
||||
"imeta".to_string(),
|
||||
format!("url {}", d.url),
|
||||
format!("m {}", d.mime_type),
|
||||
format!("x {}", d.sha256),
|
||||
format!("size {}", d.size),
|
||||
];
|
||||
if let Some(ref dim) = d.dim {
|
||||
tag.push(format!("dim {dim}"));
|
||||
}
|
||||
if let Some(ref bh) = d.blurhash {
|
||||
tag.push(format!("blurhash {bh}"));
|
||||
}
|
||||
if let Some(ref th) = d.thumb {
|
||||
tag.push(format!("thumb {th}"));
|
||||
}
|
||||
if let Some(dur) = d.duration {
|
||||
tag.push(format!("duration {dur}"));
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn image_descriptor() -> BlobDescriptor {
|
||||
BlobDescriptor {
|
||||
url: "https://relay.example.com/media/abc123.jpg".to_string(),
|
||||
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
|
||||
size: 12345,
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
uploaded: 1700000000,
|
||||
dim: Some("1920x1080".to_string()),
|
||||
blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()),
|
||||
thumb: Some("https://relay.example.com/media/abc123_thumb.jpg".to_string()),
|
||||
duration: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn video_descriptor() -> BlobDescriptor {
|
||||
BlobDescriptor {
|
||||
url: "https://relay.example.com/media/vid456.mp4".to_string(),
|
||||
sha256: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
|
||||
size: 5_000_000,
|
||||
mime_type: "video/mp4".to_string(),
|
||||
uploaded: 1700000000,
|
||||
dim: Some("1280x720".to_string()),
|
||||
blurhash: None,
|
||||
thumb: None,
|
||||
duration: Some(42.5),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_descriptor() -> BlobDescriptor {
|
||||
BlobDescriptor {
|
||||
url: "https://relay.example.com/media/min.png".to_string(),
|
||||
sha256: "0000000000000000000000000000000000000000000000000000000000000000".to_string(),
|
||||
size: 100,
|
||||
mime_type: "image/png".to_string(),
|
||||
uploaded: 1700000000,
|
||||
dim: None,
|
||||
blurhash: None,
|
||||
thumb: None,
|
||||
duration: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_imeta_tag_image() {
|
||||
let d = image_descriptor();
|
||||
let tag = build_imeta_tag(&d);
|
||||
|
||||
assert_eq!(tag[0], "imeta");
|
||||
assert_eq!(tag[1], "url https://relay.example.com/media/abc123.jpg");
|
||||
assert_eq!(tag[2], "m image/jpeg");
|
||||
assert_eq!(
|
||||
tag[3],
|
||||
"x e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
assert_eq!(tag[4], "size 12345");
|
||||
assert_eq!(tag[5], "dim 1920x1080");
|
||||
assert_eq!(tag[6], "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj");
|
||||
assert_eq!(
|
||||
tag[7],
|
||||
"thumb https://relay.example.com/media/abc123_thumb.jpg"
|
||||
);
|
||||
// No duration for images
|
||||
assert_eq!(tag.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_imeta_tag_video() {
|
||||
let d = video_descriptor();
|
||||
let tag = build_imeta_tag(&d);
|
||||
|
||||
assert_eq!(tag[0], "imeta");
|
||||
assert_eq!(tag[1], "url https://relay.example.com/media/vid456.mp4");
|
||||
assert_eq!(tag[2], "m video/mp4");
|
||||
assert_eq!(
|
||||
tag[3],
|
||||
"x abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
);
|
||||
assert_eq!(tag[4], "size 5000000");
|
||||
assert_eq!(tag[5], "dim 1280x720");
|
||||
// No blurhash or thumb
|
||||
assert_eq!(tag[6], "duration 42.5");
|
||||
assert_eq!(tag.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_imeta_tag_minimal() {
|
||||
let d = minimal_descriptor();
|
||||
let tag = build_imeta_tag(&d);
|
||||
|
||||
assert_eq!(tag.len(), 5);
|
||||
assert_eq!(tag[0], "imeta");
|
||||
assert_eq!(tag[1], "url https://relay.example.com/media/min.png");
|
||||
assert_eq!(tag[2], "m image/png");
|
||||
assert_eq!(
|
||||
tag[3],
|
||||
"x 0000000000000000000000000000000000000000000000000000000000000000"
|
||||
);
|
||||
assert_eq!(tag[4], "size 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mime_allowlist() {
|
||||
// Allowed types
|
||||
assert!(ALLOWED_MIMES.contains(&"image/jpeg"));
|
||||
assert!(ALLOWED_MIMES.contains(&"image/png"));
|
||||
assert!(ALLOWED_MIMES.contains(&"image/gif"));
|
||||
assert!(ALLOWED_MIMES.contains(&"image/webp"));
|
||||
assert!(ALLOWED_MIMES.contains(&"video/mp4"));
|
||||
|
||||
// Rejected types
|
||||
assert!(!ALLOWED_MIMES.contains(&"application/pdf"));
|
||||
assert!(!ALLOWED_MIMES.contains(&"text/plain"));
|
||||
assert!(!ALLOWED_MIMES.contains(&"image/svg+xml"));
|
||||
assert!(!ALLOWED_MIMES.contains(&"video/webm"));
|
||||
assert!(!ALLOWED_MIMES.contains(&"application/octet-stream"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_size_limits() {
|
||||
// Image limit: 50 MB
|
||||
assert_eq!(MAX_IMAGE_BYTES, 50 * 1024 * 1024);
|
||||
assert_eq!(MAX_IMAGE_BYTES, 52_428_800);
|
||||
|
||||
// Video limit: 500 MB
|
||||
assert_eq!(MAX_VIDEO_BYTES, 500 * 1024 * 1024);
|
||||
assert_eq!(MAX_VIDEO_BYTES, 524_288_000);
|
||||
|
||||
// Video limit is 10x image limit
|
||||
assert_eq!(MAX_VIDEO_BYTES, MAX_IMAGE_BYTES * 10);
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,7 @@ pub fn spawn_agent_child(
|
||||
if let Some(toolsets) = &record.mcp_toolsets {
|
||||
command.env("SPROUT_TOOLSETS", toolsets);
|
||||
} else {
|
||||
command.env_remove("SPROUT_TOOLSETS");
|
||||
command.env("SPROUT_TOOLSETS", "default,media");
|
||||
}
|
||||
command.env_remove("SPROUT_ACP_PRIVATE_KEY");
|
||||
command.env_remove("SPROUT_ACP_API_TOKEN");
|
||||
|
||||
Reference in New Issue
Block a user