use crate::providers::ToolDefinition;
use crate::tools::ToolEffect;
const MCP_SEPARATOR: &str = "__";
pub fn format_mcp_tool_name(server: &str, tool: &str) -> String {
format!("{server}{MCP_SEPARATOR}{tool}")
}
pub fn parse_mcp_tool_name(name: &str) -> Option<(&str, &str)> {
let idx = name.find(MCP_SEPARATOR)?;
let server = &name[..idx];
let tool = &name[idx + MCP_SEPARATOR.len()..];
if server.is_empty() || tool.is_empty() {
return None;
}
Some((server, tool))
}
pub fn is_mcp_tool_name(name: &str) -> bool {
parse_mcp_tool_name(name).is_some()
}
pub fn classify_mcp_tool(annotations: Option<&McpToolAnnotations>) -> ToolEffect {
let Some(ann) = annotations else {
return ToolEffect::RemoteAction;
};
if ann.read_only_hint == Some(true) {
return ToolEffect::ReadOnly;
}
if ann.destructive_hint == Some(true) {
return ToolEffect::Destructive;
}
ToolEffect::RemoteAction
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct McpToolAnnotations {
pub read_only_hint: Option<bool>,
pub destructive_hint: Option<bool>,
}
pub fn mcp_tool_to_definition(
server_name: &str,
tool: &rmcp::model::Tool,
) -> (ToolDefinition, McpToolAnnotations) {
let qualified_name = format_mcp_tool_name(server_name, &tool.name);
let schema_value = serde_json::to_value(&tool.input_schema).unwrap_or_default();
let mut params = if schema_value.is_object() {
schema_value
} else {
serde_json::json!({"type": "object", "properties": {}})
};
if let Some(obj) = params.as_object_mut()
&& !obj.contains_key("properties")
{
obj.insert(
"properties".to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
let description = tool
.description
.as_deref()
.unwrap_or("MCP tool (no description)")
.to_string();
let def = ToolDefinition {
name: qualified_name,
description: format!("[MCP:{server_name}] {description}"),
parameters: params,
};
let annotations = tool
.annotations
.as_ref()
.map(|ann| McpToolAnnotations {
read_only_hint: ann.read_only_hint,
destructive_hint: ann.destructive_hint,
})
.unwrap_or_default();
(def, annotations)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_and_parse_roundtrip() {
let name = format_mcp_tool_name("playwright", "navigate");
assert_eq!(name, "playwright__navigate");
let (server, tool) = parse_mcp_tool_name(&name).unwrap();
assert_eq!(server, "playwright");
assert_eq!(tool, "navigate");
}
#[test]
fn parse_tool_with_underscores() {
let name = format_mcp_tool_name("github", "create_pull_request");
assert_eq!(name, "github__create_pull_request");
let (server, tool) = parse_mcp_tool_name(&name).unwrap();
assert_eq!(server, "github");
assert_eq!(tool, "create_pull_request");
}
#[test]
fn parse_rejects_no_separator() {
assert!(parse_mcp_tool_name("plain_tool").is_none());
}
#[test]
fn parse_rejects_empty_parts() {
assert!(parse_mcp_tool_name("__tool").is_none());
assert!(parse_mcp_tool_name("server__").is_none());
}
#[test]
fn is_mcp_tool_name_works() {
assert!(is_mcp_tool_name("playwright__navigate"));
assert!(!is_mcp_tool_name("Read"));
assert!(!is_mcp_tool_name("Bash"));
}
#[test]
fn classify_no_annotations() {
assert_eq!(classify_mcp_tool(None), ToolEffect::RemoteAction);
}
#[test]
fn classify_read_only() {
let ann = McpToolAnnotations {
read_only_hint: Some(true),
destructive_hint: None,
};
assert_eq!(classify_mcp_tool(Some(&ann)), ToolEffect::ReadOnly);
}
#[test]
fn classify_destructive() {
let ann = McpToolAnnotations {
read_only_hint: None,
destructive_hint: Some(true),
};
assert_eq!(classify_mcp_tool(Some(&ann)), ToolEffect::Destructive);
}
#[test]
fn classify_read_only_beats_destructive() {
let ann = McpToolAnnotations {
read_only_hint: Some(true),
destructive_hint: Some(true),
};
assert_eq!(classify_mcp_tool(Some(&ann)), ToolEffect::ReadOnly);
}
#[test]
fn classify_explicit_false() {
let ann = McpToolAnnotations {
read_only_hint: Some(false),
destructive_hint: Some(false),
};
assert_eq!(classify_mcp_tool(Some(&ann)), ToolEffect::RemoteAction);
}
}