anyclaw-sdk-tool 0.3.10

Tool SDK for anyclaw — build MCP-compatible tool servers
Documentation
//! Media delivery trait for MCP tools.
//!
//! Tools that need to send files/images/audio to the user implement or receive
//! a [`MediaDelivery`] handle. This replaces the old pattern of uploading to
//! the media service and returning a `media://` URI.
//!
//! Requires the `channel.deliver` scope.

use std::future::Future;
use std::pin::Pin;

use anyclaw_sdk_types::DeliverMedia;

use crate::error::ToolSdkError;

/// Trait for delivering media directly to a channel.
///
/// Tools receive a `dyn DynMediaDelivery` handle during initialization (when
/// the `channel.deliver` scope is granted). The handle routes media bytes
/// through anyclaw to the session's channel without intermediate storage.
///
/// # Example
///
/// ```ignore
/// // In a tool's execute() method:
/// let bytes = workspace.read_file(Path::new("/output/chart.png")).await?;
/// media.deliver(DeliverMedia {
///     session_id: session_id.clone(),
///     data: bytes,
///     mime_type: "image/png".into(),
///     filename: Some("chart.png".into()),
///     category: MediaCategory::Image,
/// }).await?;
/// ```
pub trait MediaDelivery: Send + Sync + 'static {
    /// Deliver media bytes to the session's channel.
    ///
    /// Returns `Ok(())` when the channel acknowledges receipt.
    /// Returns `Err` if delivery fails (channel unavailable, scope denied, etc.).
    fn deliver(&self, media: DeliverMedia)
    -> impl Future<Output = Result<(), ToolSdkError>> + Send;
}

/// Object-safe alias for [`MediaDelivery`].
///
/// Use `Arc<dyn DynMediaDelivery>` when the concrete type is not known at
/// compile time (e.g., injecting delivery handles into tool contexts).
pub trait DynMediaDelivery: Send + Sync + 'static {
    /// Deliver media bytes (boxed future for object safety).
    fn deliver<'a>(
        &'a self,
        media: DeliverMedia,
    ) -> Pin<Box<dyn Future<Output = Result<(), ToolSdkError>> + Send + 'a>>;
}

impl<T: MediaDelivery> DynMediaDelivery for T {
    fn deliver<'a>(
        &'a self,
        media: DeliverMedia,
    ) -> Pin<Box<dyn Future<Output = Result<(), ToolSdkError>> + Send + 'a>> {
        Box::pin(MediaDelivery::deliver(self, media))
    }
}

/// A no-op implementation that always returns an error.
///
/// Used when the tool does not have the `channel.deliver` scope granted.
/// Tools that attempt media delivery without the scope get a clear error.
#[derive(Debug, Clone, Copy)]
pub struct NoopMediaDelivery;

impl MediaDelivery for NoopMediaDelivery {
    async fn deliver(&self, _media: DeliverMedia) -> Result<(), ToolSdkError> {
        Err(ToolSdkError::ExecutionFailed(
            "media delivery not available: channel.deliver scope not granted".into(),
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use anyclaw_sdk_types::MediaCategory;
    use rstest::rstest;

    #[rstest]
    fn when_dyn_media_delivery_used_as_trait_object_then_compiles() {
        fn _accepts_dyn(_d: &dyn DynMediaDelivery) {}
    }

    #[rstest]
    #[tokio::test]
    async fn when_noop_delivery_called_then_returns_scope_error() {
        let noop = NoopMediaDelivery;
        let media = DeliverMedia {
            session_id: "test-session".into(),
            data: vec![0x89, 0x50, 0x4E, 0x47],
            mime_type: "image/png".into(),
            filename: Some("test.png".into()),
            category: MediaCategory::Image,
        };
        let result = MediaDelivery::deliver(&noop, media).await;
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.to_string()
                .contains("channel.deliver scope not granted")
        );
    }

    #[rstest]
    #[tokio::test]
    async fn when_noop_delivery_used_as_dyn_then_returns_same_error() {
        let noop: Box<dyn DynMediaDelivery> = Box::new(NoopMediaDelivery);
        let media = DeliverMedia {
            session_id: "s1".into(),
            data: vec![1, 2, 3],
            mime_type: "application/octet-stream".into(),
            filename: None,
            category: MediaCategory::File,
        };
        let result = noop.deliver(media).await;
        assert!(result.is_err());
    }
}