use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpTool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: McpInputSchema,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<McpToolAnnotations>,
}
impl McpTool {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
input_schema: McpInputSchema,
) -> Self {
Self {
name: name.into(),
title: None,
description: Some(description.into()),
input_schema,
output_schema: None,
annotations: None,
}
}
pub fn from_schema(
name: impl Into<String>,
description: impl Into<String>,
schema: Value,
) -> Self {
let properties = schema.get("properties").cloned();
let required = schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let input_schema = McpInputSchema {
schema_type: schema
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("object")
.to_string(),
properties,
required,
additional: None,
};
Self::new(name, description, input_schema)
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_read_only_hint(mut self, read_only: bool) -> Self {
self.annotations
.get_or_insert_with(Default::default)
.read_only_hint = Some(read_only);
self
}
pub fn with_destructive_hint(mut self, destructive: bool) -> Self {
self.annotations
.get_or_insert_with(Default::default)
.destructive_hint = Some(destructive);
self
}
pub fn with_idempotent_hint(mut self, idempotent: bool) -> Self {
self.annotations
.get_or_insert_with(Default::default)
.idempotent_hint = Some(idempotent);
self
}
pub fn with_open_world_hint(mut self, open_world: bool) -> Self {
self.annotations
.get_or_insert_with(Default::default)
.open_world_hint = Some(open_world);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpInputSchema {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub properties: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required: Vec<String>,
#[serde(flatten)]
pub additional: Option<Value>,
}
impl McpInputSchema {
pub fn object(properties: Value, required: Vec<String>) -> Self {
Self {
schema_type: "object".to_string(),
properties: Some(properties),
required,
additional: None,
}
}
pub fn empty() -> Self {
Self {
schema_type: "object".to_string(),
properties: None,
required: vec![],
additional: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct McpToolAnnotations {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub read_only_hint: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub destructive_hint: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub idempotent_hint: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open_world_hint: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCall {
pub name: String,
#[serde(default)]
pub arguments: Value,
}
impl McpToolCall {
pub fn new(name: impl Into<String>, arguments: Value) -> Self {
Self {
name: name.into(),
arguments,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolResult {
pub content: Vec<McpContent>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_error: bool,
}
impl McpToolResult {
pub fn text(content: impl Into<String>) -> Self {
Self {
content: vec![McpContent::Text {
text: content.into(),
}],
is_error: false,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
content: vec![McpContent::Text {
text: message.into(),
}],
is_error: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum McpContent {
Text { text: String },
Image { data: String, mime_type: String },
Resource {
uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_mcp_tool_new() {
let input_schema = McpInputSchema::object(
json!({
"query": {"type": "string"}
}),
vec!["query".to_string()],
);
let tool = McpTool::new("search", "Search for items", input_schema);
assert_eq!(tool.name, "search");
assert_eq!(tool.description, Some("Search for items".to_string()));
assert!(tool.title.is_none());
assert!(tool.annotations.is_none());
}
#[test]
fn test_mcp_tool_from_schema() {
let schema = json!({
"type": "object",
"properties": {
"location": {"type": "string"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
});
let tool = McpTool::from_schema("get_weather", "Get current weather", schema);
assert_eq!(tool.name, "get_weather");
assert_eq!(
tool.description,
Some("Get current weather".to_string())
);
assert_eq!(tool.input_schema.schema_type, "object");
assert_eq!(tool.input_schema.required, vec!["location"]);
assert!(tool.input_schema.properties.is_some());
}
#[test]
fn test_mcp_tool_builder_methods() {
let input_schema = McpInputSchema::empty();
let tool = McpTool::new("list_files", "List files in directory", input_schema)
.with_title("List Files")
.with_read_only_hint(true)
.with_idempotent_hint(true);
assert_eq!(tool.title, Some("List Files".to_string()));
let annotations = tool.annotations.unwrap();
assert_eq!(annotations.read_only_hint, Some(true));
assert_eq!(annotations.idempotent_hint, Some(true));
assert!(annotations.destructive_hint.is_none());
}
#[test]
fn test_mcp_tool_destructive_hint() {
let input_schema = McpInputSchema::empty();
let tool = McpTool::new("delete_file", "Delete a file", input_schema)
.with_destructive_hint(true)
.with_open_world_hint(false);
let annotations = tool.annotations.unwrap();
assert_eq!(annotations.destructive_hint, Some(true));
assert_eq!(annotations.open_world_hint, Some(false));
}
#[test]
fn test_mcp_tool_serialization() {
let tool = McpTool {
name: "get_weather".to_string(),
title: Some("Get Weather".to_string()),
description: Some("Get current weather for a location".to_string()),
input_schema: McpInputSchema::object(
json!({
"location": {
"type": "string",
"description": "City name"
}
}),
vec!["location".to_string()],
),
output_schema: None,
annotations: Some(McpToolAnnotations {
read_only_hint: Some(true),
..Default::default()
}),
};
let json = serde_json::to_string(&tool).unwrap();
assert!(json.contains("inputSchema"));
assert!(json.contains("readOnlyHint"));
let parsed: McpTool = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "get_weather");
}
#[test]
fn test_mcp_tool_result() {
let result = McpToolResult::text("72°F, sunny");
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
let error = McpToolResult::error("Location not found");
assert!(error.is_error);
}
#[test]
fn test_mcp_content_types() {
let text = McpContent::Text {
text: "Hello".to_string(),
};
let json = serde_json::to_string(&text).unwrap();
assert!(json.contains("\"type\":\"text\""));
let image = McpContent::Image {
data: "base64data".to_string(),
mime_type: "image/png".to_string(),
};
let json = serde_json::to_string(&image).unwrap();
assert!(json.contains("\"type\":\"image\""));
}
}