tools-kit 0.0.2

Portable Local and MCP tool execution with narrow extension traits.
Documentation
use std::collections::BTreeMap;
#[cfg(feature = "mcp-server")]
use std::{borrow::Cow, sync::Arc};

#[cfg(feature = "mcp-server")]
use rmcp::ErrorData as McpError;
use rmcp::model::{CallToolResult, Content, RawContent, Tool};
use serde_json::Map;

use crate::{
    ToolError,
    declaration::{ToolAnnotations, ToolIcon, ToolInfo},
    execution::{ToolContent, ToolResult},
    interaction::ToolInteractionCapabilities,
    mcp::McpClientConfig,
    name::{McpToolNameContext, ToolName},
    schema::{JsonSchema, SchemaStrictness},
};

use super::client::McpClient;

#[derive(Clone)]
pub(crate) struct McpToolEntry {
    info: ToolInfo,
    client: McpClient,
}

impl McpToolEntry {
    pub(crate) fn info(&self) -> &ToolInfo {
        &self.info
    }

    pub(crate) fn client(&self) -> &McpClient {
        &self.client
    }
}

impl std::fmt::Debug for McpToolEntry {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("McpToolEntry")
            .field("info", &self.info)
            .finish_non_exhaustive()
    }
}

pub(crate) struct McpProtocol;

impl McpProtocol {
    #[cfg(feature = "mcp-server")]
    pub(crate) fn tool_info_to_mcp(info: &ToolInfo) -> Result<Tool, ToolError> {
        let input_schema = Self::schema_to_mcp_object(&info.input_schema.schema)?;
        let output_schema = info
            .output_schema
            .as_ref()
            .map(|schema| Self::schema_to_mcp_object(&schema.schema))
            .transpose()?;
        let mut tool = Tool::new_with_raw(
            info.name.as_str().to_owned(),
            info.description.clone().map(Cow::Owned),
            Arc::new(input_schema),
        );
        tool.title.clone_from(&info.title);
        tool.output_schema = output_schema.map(Arc::new);
        tool.annotations = Some(Self::annotations_to_mcp(&info.annotations));
        if !info.icons.is_empty() {
            tool.icons = Some(
                info.icons
                    .iter()
                    .map(|icon| {
                        let mut mapped = rmcp::model::Icon::new(icon.uri.clone());
                        mapped.mime_type.clone_from(&icon.mime_type);
                        mapped
                    })
                    .collect(),
            );
        }
        if !info.metadata.is_empty() {
            tool.meta = Some(Self::metadata_to_meta(&info.metadata));
        }
        Ok(tool)
    }

    pub(crate) fn entry_from_mcp_tool(
        config: &McpClientConfig,
        client: McpClient,
        names: &mut McpToolNameContext,
        tool: Tool,
    ) -> Result<McpToolEntry, ToolError> {
        let native_name = tool.name.to_string();
        let name = ToolName::mcp(names, native_name)?;
        let input_schema = JsonSchema {
            schema: serde_json::Value::Object((*tool.input_schema).clone()),
            strictness: SchemaStrictness::Unspecified,
        };
        let output_schema = tool.output_schema.map(|schema| JsonSchema {
            schema: serde_json::Value::Object((*schema).clone()),
            strictness: SchemaStrictness::Unspecified,
        });
        let metadata = tool.meta.map(Self::meta_to_metadata).unwrap_or_default();
        let interactions = ToolInteractionCapabilities {
            form: config.supports_forms(),
            form_schema_validation: config.validates_forms(),
            text: false,
            multiline_text: false,
        };
        let info = ToolInfo {
            name,
            title: tool.title,
            description: tool.description.map(|description| description.into_owned()),
            input_schema,
            output_schema,
            annotations: Self::map_annotations(tool.annotations),
            icons: Self::map_icons(tool.icons),
            interactions,
            metadata,
        };
        Ok(McpToolEntry { info, client })
    }

    #[cfg(feature = "mcp-server")]
    pub(crate) fn tool_result_to_mcp(mut result: ToolResult) -> Result<CallToolResult, ToolError> {
        let mut content = result
            .content
            .into_iter()
            .map(Self::tool_content_to_mcp)
            .collect::<Result<Vec<_>, _>>()?;
        let structured_content = result.structured_content.take();
        if content.is_empty()
            && let Some(value) = &structured_content
        {
            content.push(Content::text(value.to_string()));
        }
        let mut mapped = if result.is_error {
            CallToolResult::error(content)
        } else {
            CallToolResult::success(content)
        };
        mapped.structured_content = structured_content;
        mapped.is_error = Some(result.is_error);
        if !result.metadata.is_empty() {
            mapped.meta = Some(Self::metadata_to_meta(&result.metadata));
        }
        Ok(mapped)
    }

    #[cfg(feature = "mcp-server")]
    pub(crate) fn tool_error_to_mcp_error(error: ToolError) -> McpError {
        if matches!(
            &error,
            ToolError::UnknownTool { .. }
                | ToolError::InvalidArguments { .. }
                | ToolError::InvalidInteraction { .. }
        ) {
            McpError::invalid_params(error.to_string(), None)
        } else {
            McpError::internal_error(error.to_string(), None)
        }
    }

    pub(crate) fn tool_result_from_mcp(result: CallToolResult) -> Result<ToolResult, ToolError> {
        Ok(ToolResult {
            content: result
                .content
                .into_iter()
                .map(Self::tool_content_from_mcp)
                .collect::<Result<Vec<_>, _>>()?,
            structured_content: result.structured_content,
            is_error: result.is_error.unwrap_or(false),
            metadata: result.meta.map(Self::meta_to_metadata).unwrap_or_default(),
        })
    }

    fn tool_content_from_mcp(content: Content) -> Result<ToolContent, ToolError> {
        let annotations = content.annotations;
        match content.raw {
            RawContent::Text(text) => Ok(ToolContent::Text {
                metadata: Self::content_metadata(text.meta, annotations),
                text: text.text,
            }),
            RawContent::Image(image) => Ok(ToolContent::Image {
                metadata: Self::content_metadata(image.meta, annotations),
                mime_type: image.mime_type,
                data_base64: image.data,
            }),
            RawContent::Audio(audio) => Ok(ToolContent::Audio {
                metadata: Self::content_metadata(None, annotations),
                mime_type: audio.mime_type,
                data_base64: audio.data,
            }),
            RawContent::Resource(resource) => {
                let metadata = Self::content_metadata(resource.meta.clone(), annotations);
                let value = serde_json::to_value(resource.resource).map_err(|error| {
                    ToolError::Protocol {
                        message: format!("failed to serialize MCP embedded resource: {error}"),
                    }
                })?;
                Ok(ToolContent::EmbeddedResource {
                    uri: value
                        .get("uri")
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or_default()
                        .to_owned(),
                    contents: value,
                    metadata: metadata.unwrap_or(serde_json::Value::Null),
                })
            }
            RawContent::ResourceLink(resource) => Ok(ToolContent::ResourceLink {
                uri: resource.uri,
                title: Some(resource.name),
                metadata: Self::content_metadata(resource.meta, annotations)
                    .unwrap_or(serde_json::Value::Null),
            }),
        }
    }

    fn content_metadata(
        meta: Option<rmcp::model::Meta>,
        annotations: Option<rmcp::model::Annotations>,
    ) -> Option<serde_json::Value> {
        let mut object = Map::new();
        if let Some(meta) = meta {
            object.extend(Self::meta_to_metadata(meta));
        }
        if let Some(annotations) = annotations
            && let Ok(serde_json::Value::Object(annotation_object)) =
                serde_json::to_value(annotations)
        {
            object.extend(annotation_object);
        }
        if object.is_empty() {
            None
        } else {
            Some(serde_json::Value::Object(object))
        }
    }

    fn map_annotations(annotations: Option<rmcp::model::ToolAnnotations>) -> ToolAnnotations {
        let Some(annotations) = annotations else {
            return ToolAnnotations::default();
        };
        ToolAnnotations {
            title: annotations.title,
            read_only_hint: annotations.read_only_hint,
            destructive_hint: annotations.destructive_hint,
            idempotent_hint: annotations.idempotent_hint,
            open_world_hint: annotations.open_world_hint,
        }
    }

    fn map_icons(icons: Option<Vec<rmcp::model::Icon>>) -> Vec<ToolIcon> {
        icons
            .unwrap_or_default()
            .into_iter()
            .map(|icon| ToolIcon {
                uri: icon.src,
                mime_type: icon.mime_type,
            })
            .collect()
    }

    fn meta_to_metadata(meta: rmcp::model::Meta) -> BTreeMap<String, serde_json::Value> {
        match serde_json::to_value(meta) {
            Ok(serde_json::Value::Object(object)) => object.into_iter().collect(),
            _ => BTreeMap::new(),
        }
    }

    #[cfg(feature = "mcp-server")]
    fn schema_to_mcp_object(
        schema: &serde_json::Value,
    ) -> Result<rmcp::model::JsonObject, ToolError> {
        let serde_json::Value::Object(object) = schema else {
            return Err(ToolError::Protocol {
                message: "MCP tool schemas must be JSON objects".to_owned(),
            });
        };
        Ok(object.clone())
    }

    #[cfg(feature = "mcp-server")]
    fn annotations_to_mcp(annotations: &ToolAnnotations) -> rmcp::model::ToolAnnotations {
        rmcp::model::ToolAnnotations::from_raw(
            annotations.title.clone(),
            annotations.read_only_hint,
            annotations.destructive_hint,
            annotations.idempotent_hint,
            annotations.open_world_hint,
        )
    }

    #[cfg(feature = "mcp-server")]
    fn tool_content_to_mcp(content: ToolContent) -> Result<Content, ToolError> {
        use rmcp::model::{
            RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent,
            ResourceContents,
        };

        match content {
            ToolContent::Text { text, metadata } => Ok(Content::new(
                RawContent::Text(RawTextContent {
                    text,
                    meta: metadata.and_then(Self::metadata_value_to_meta),
                }),
                None,
            )),
            ToolContent::Json { value, metadata } => Ok(Content::new(
                RawContent::Text(RawTextContent {
                    text: value.to_string(),
                    meta: metadata.and_then(Self::metadata_value_to_meta),
                }),
                None,
            )),
            ToolContent::Image {
                mime_type,
                data_base64,
                metadata,
            } => Ok(Content::new(
                RawContent::Image(RawImageContent {
                    data: data_base64,
                    mime_type,
                    meta: metadata.and_then(Self::metadata_value_to_meta),
                }),
                None,
            )),
            ToolContent::Audio {
                mime_type,
                data_base64,
                ..
            } => Ok(Content::new(
                RawContent::Audio(RawAudioContent {
                    data: data_base64,
                    mime_type,
                }),
                None,
            )),
            ToolContent::ResourceLink {
                uri,
                title,
                metadata,
            } => {
                let name = title.clone().unwrap_or_else(|| uri.clone());
                let mut resource = RawResource::new(uri, name);
                resource.title = title;
                resource.meta = Self::metadata_value_to_meta(metadata);
                Ok(Content::new(RawContent::ResourceLink(resource), None))
            }
            ToolContent::EmbeddedResource {
                uri,
                contents,
                metadata,
            } => {
                let resource =
                    serde_json::from_value::<ResourceContents>(contents).map_err(|error| {
                        ToolError::Protocol {
                            message: format!(
                                "failed to map embedded resource content to MCP: {error}"
                            ),
                        }
                    })?;
                Ok(Content::new(
                    RawContent::Resource(RawEmbeddedResource {
                        meta: Self::metadata_value_to_meta(metadata).or_else(|| {
                            Some(rmcp::model::Meta(Map::from_iter([(
                                "uri".to_owned(),
                                serde_json::Value::String(uri),
                            )])))
                        }),
                        resource,
                    }),
                    None,
                ))
            }
        }
    }

    #[cfg(feature = "mcp-server")]
    fn metadata_to_meta(metadata: &BTreeMap<String, serde_json::Value>) -> rmcp::model::Meta {
        rmcp::model::Meta(metadata.clone().into_iter().collect())
    }

    #[cfg(feature = "mcp-server")]
    fn metadata_value_to_meta(value: serde_json::Value) -> Option<rmcp::model::Meta> {
        match value {
            serde_json::Value::Object(object) if object.is_empty() => None,
            serde_json::Value::Object(object) => Some(rmcp::model::Meta(object)),
            serde_json::Value::Null => None,
            other => Some(rmcp::model::Meta(Map::from_iter([(
                "value".to_owned(),
                other,
            )]))),
        }
    }
}