use rho_sdk::{
model::ToolSpec,
tool::{OperationKind, ToolMetadata},
};
use rmcp::model::{Tool as RemoteTool, ToolAnnotations};
use super::{config::McpTransport, result::ResultExpectation, tool::namespaced_tool_name};
#[derive(Clone, Debug, PartialEq)]
pub(super) struct McpToolDefinition {
pub(super) spec: ToolSpec,
pub(super) expectation: ResultExpectation,
pub(super) presentation: McpToolPresentation,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct McpToolPresentation {
read_only: bool,
notices: Vec<String>,
}
impl McpToolDefinition {
pub(super) fn from_remote(identity: &str, remote_name: &str, remote: &RemoteTool) -> Self {
let annotations = remote.annotations.as_ref();
let description = remote
.description
.as_deref()
.unwrap_or("No description supplied by the MCP server");
let title = annotations
.and_then(|annotations| annotations.title.as_deref())
.or(remote.title.as_deref());
let mut description = match title {
Some(title) => format!("MCP server `{identity}`, {title}: {description}"),
None => format!("MCP server `{identity}`: {description}"),
};
for hint in behavior_hints(annotations) {
description.push_str(&format!("\nServer hint: {hint}."));
}
Self {
spec: ToolSpec {
name: namespaced_tool_name(identity, remote_name),
description,
input_schema: serde_json::Value::Object((*remote.input_schema).clone()),
},
expectation: ResultExpectation {
output_schema: remote
.output_schema
.as_ref()
.map(|schema| serde_json::Value::Object((**schema).clone())),
},
presentation: McpToolPresentation {
read_only: annotations
.is_some_and(|annotations| annotations.read_only_hint.unwrap_or(false)),
notices: behavior_hints(annotations)
.into_iter()
.map(|hint| format!("Server hint: {hint}"))
.collect(),
},
}
}
}
impl McpToolPresentation {
pub(super) fn metadata(&self, transport: &McpTransport) -> ToolMetadata {
let mut metadata = match transport {
McpTransport::Stdio { command, args, .. } => ToolMetadata::new()
.operation(if self.read_only {
OperationKind::Read
} else {
OperationKind::Execute
})
.command_summary(format!("{command} ({} arguments)", args.len())),
McpTransport::StreamableHttp { url, .. } => ToolMetadata::new()
.operation(if self.read_only {
OperationKind::Read
} else {
OperationKind::Network
})
.url(url.clone()),
};
for notice in &self.notices {
metadata = metadata.presentation_notice(notice.clone());
}
metadata
}
}
fn behavior_hints(annotations: Option<&ToolAnnotations>) -> Vec<&'static str> {
let Some(annotations) = annotations else {
return Vec::new();
};
let read_only = annotations.read_only_hint.unwrap_or(false);
let mut hints = Vec::new();
if read_only {
hints.push("this tool only reads");
} else if annotations.destructive_hint.unwrap_or(false) {
hints.push("this tool may make destructive changes");
}
if annotations.open_world_hint.unwrap_or(false) {
hints.push("this tool reaches systems outside this machine");
}
hints
}
#[cfg(test)]
#[path = "definition_tests.rs"]
mod tests;