use std::sync::Arc;
use rmcp::model::{
CallToolResult, Content, ErrorData, ListToolsResult, RawContent, Tool, ToolAnnotations,
};
use rskit_errors::{AppError, AppResult, ErrorCode};
use rskit_tool::result::ToolResult;
use rskit_tool::{
Annotations, Definition, Envelope, NetworkPolicy, Safety, ToolOutput, ToolSchema,
};
pub fn definition_to_tool(def: &Definition, prefix: &str) -> Tool {
let name = if prefix.is_empty() {
def.name.clone()
} else {
format!("{prefix}{}", def.name)
};
let input_schema = value_to_json_object(def.input_schema.as_json());
let mut tool = Tool::new(name, def.description.clone(), input_schema);
tool = tool.with_annotations(to_mcp_annotations(def));
if let Some(ref output_schema) = def.output_schema
&& let Some(obj) = output_schema.as_json().as_object()
{
tool = tool.with_raw_output_schema(Arc::new(obj.clone()));
}
tool
}
fn to_mcp_annotations(def: &Definition) -> ToolAnnotations {
let read_only = matches!(def.envelope.safety, Safety::ReadOnly);
let destructive = matches!(def.envelope.safety, Safety::Destructive);
let open_world = match &def.envelope.network {
NetworkPolicy::None => false,
NetworkPolicy::AllowList { rules } => !rules.is_empty(),
} || !def.envelope.filesystem.is_empty()
|| !def.envelope.subprocess.is_empty();
let mut annotations = if def.annotations.title.is_empty() {
ToolAnnotations::new()
} else {
ToolAnnotations::with_title(def.annotations.title.clone())
};
annotations = annotations.read_only(read_only);
annotations = annotations.destructive(destructive);
annotations = annotations.open_world(open_world);
if let Some(idempotent) = def.annotations.idempotent_hint {
annotations = annotations.idempotent(idempotent);
}
annotations
}
pub fn definitions_to_list_result(defs: &[Definition], prefix: &str) -> ListToolsResult {
let tools: Vec<Tool> = defs.iter().map(|d| definition_to_tool(d, prefix)).collect();
ListToolsResult {
tools,
next_cursor: None,
meta: None,
}
}
pub fn tool_to_definition(tool: &Tool, prefix: &str) -> AppResult<Definition> {
let raw_name = tool.name.as_ref();
let name = if !prefix.is_empty() && raw_name.starts_with(prefix) {
raw_name[prefix.len()..].to_string()
} else {
raw_name.to_string()
};
let input_schema = mcp_schema_to_tool_schema(raw_name, "input", tool.schema_as_json_value())?;
let output_schema = tool
.output_schema
.as_ref()
.map(|schema| {
let value = serde_json::to_value(schema.as_ref()).map_err(|err| {
AppError::new(
ErrorCode::InvalidInput,
format!("invalid MCP output schema for tool {raw_name:?}: {err}"),
)
.with_cause(err)
})?;
mcp_schema_to_tool_schema(raw_name, "output", value)
})
.transpose()?;
let annotations = tool
.annotations
.as_ref()
.map_or_else(Annotations::default, |a| Annotations {
title: a.title.clone().unwrap_or_default(),
idempotent_hint: a.idempotent_hint,
..Annotations::default()
});
let read_only = tool
.annotations
.as_ref()
.and_then(|a| a.read_only_hint)
.unwrap_or(false);
let destructive = tool
.annotations
.as_ref()
.and_then(|a| a.destructive_hint)
.unwrap_or(false);
Ok(Definition {
name,
description: tool.description.as_deref().unwrap_or("").to_string(),
input_schema,
output_schema,
annotations,
envelope: Envelope {
safety: if destructive {
Safety::Destructive
} else if read_only {
Safety::ReadOnly
} else {
Safety::Mutating
},
..Envelope::default()
},
})
}
fn mcp_schema_to_tool_schema(
raw_name: &str,
schema_kind: &str,
value: serde_json::Value,
) -> AppResult<ToolSchema> {
ToolSchema::new(value).map_err(|err| {
let message = err.message().to_owned();
AppError::new(
ErrorCode::InvalidInput,
format!("invalid MCP {schema_kind} schema for tool {raw_name:?}: {message}"),
)
.with_cause(err)
})
}
pub fn tool_result_to_call_result(result: &ToolResult) -> CallToolResult {
let content = vec![Content::text(&result.content)];
if result.is_error {
let mut r = CallToolResult::error(content);
if let Some(ref output) = result.output {
r.structured_content = Some(output.as_json().clone());
}
r
} else {
match &result.output {
Some(output) => {
let mut r = CallToolResult::structured(output.as_json().clone());
r.content = content;
r
}
None => CallToolResult::success(content),
}
}
}
pub fn app_error_to_mcp_error(err: &rskit_errors::AppError) -> ErrorData {
ErrorData::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
err.message().to_string(),
None,
)
}
pub fn call_result_to_tool_result(result: &CallToolResult) -> ToolResult {
let content: String = result
.content
.iter()
.filter_map(|c| {
if let RawContent::Text(text) = &c.raw {
Some(text.text.as_str())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n");
let output = result.structured_content.clone().map(ToolOutput::from);
let is_error = result.is_error.unwrap_or(false);
ToolResult {
output,
content,
is_error,
metadata: std::collections::HashMap::new(),
}
}
fn value_to_json_object(value: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
if let serde_json::Value::Object(map) = value {
map.clone()
} else {
let mut map = serde_json::Map::new();
map.insert(
"type".to_string(),
serde_json::Value::String("object".to_string()),
);
map
}
}
#[cfg(test)]
mod tests {
use super::*;
use rskit_tool::result::{ToolResult, error_result, text_result};
use rskit_tool::{
Annotations, Definition, Envelope, FilesystemMode, FilesystemRule, NetworkPolicy,
NetworkRule, Safety,
};
use serde_json::json;
use std::error::Error;
fn sample_definition() -> Definition {
Definition {
name: "search".to_string(),
description: "Search the web".to_string(),
input_schema: ToolSchema::new(json!({
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
}))
.unwrap(),
output_schema: None,
annotations: Annotations {
title: "Web Search".to_string(),
idempotent_hint: Some(true),
category: "web".to_string(),
tags: vec!["search".to_string()],
..Annotations::default()
},
envelope: Envelope {
network: NetworkPolicy::AllowList {
rules: vec![NetworkRule {
host: "example.com".to_string(),
port: None,
scheme: Some("https".to_string()),
}],
},
filesystem: vec![FilesystemRule {
path: "/data".to_string(),
mode: FilesystemMode::Read,
}],
safety: Safety::ReadOnly,
..Envelope::default()
},
}
}
#[test]
fn test_definition_to_tool_no_prefix() {
let def = sample_definition();
let tool = definition_to_tool(&def, "");
assert_eq!(tool.name.as_ref(), "search");
assert_eq!(tool.description.as_deref(), Some("Search the web"));
let ann = tool.annotations.as_ref().unwrap();
assert_eq!(ann.title.as_deref(), Some("Web Search"));
assert_eq!(ann.read_only_hint, Some(true));
}
#[test]
fn test_definition_to_tool_with_prefix() {
let def = sample_definition();
let tool = definition_to_tool(&def, "myserver_");
assert_eq!(tool.name.as_ref(), "myserver_search");
}
#[test]
fn test_tool_to_definition_strips_prefix() {
let def = sample_definition();
let tool = definition_to_tool(&def, "myserver_");
let round_tripped = tool_to_definition(&tool, "myserver_").unwrap();
assert_eq!(round_tripped.name, "search");
assert_eq!(round_tripped.description, "Search the web");
}
#[test]
fn test_tool_to_definition_no_prefix() {
let def = sample_definition();
let tool = definition_to_tool(&def, "");
let round_tripped = tool_to_definition(&tool, "").unwrap();
assert_eq!(round_tripped.name, "search");
}
#[test]
fn mcp_schema_error_labels_invalid_input_schema_with_tool_name() {
let error = mcp_schema_to_tool_schema("broken", "input", json!("not-an-object"))
.expect_err("invalid input schema rejected");
assert!(
error
.message()
.contains("invalid MCP input schema for tool \"broken\"")
);
assert!(error.source().is_some());
}
#[test]
fn mcp_schema_error_labels_invalid_output_schema_with_tool_name() {
let error = mcp_schema_to_tool_schema("broken", "output", json!("not-an-object"))
.expect_err("invalid output schema rejected");
assert!(
error
.message()
.contains("invalid MCP output schema for tool \"broken\"")
);
assert!(error.source().is_some());
}
#[test]
fn test_tool_result_to_call_result_success() {
let result = text_result("hello world");
let mcp_result = tool_result_to_call_result(&result);
assert_eq!(mcp_result.content.len(), 1);
assert_eq!(mcp_result.is_error, Some(false));
}
#[test]
fn test_tool_result_to_call_result_error() {
let result = error_result("something failed");
let mcp_result = tool_result_to_call_result(&result);
assert_eq!(mcp_result.is_error, Some(true));
}
#[test]
fn test_tool_result_with_structured_output() {
let result = ToolResult {
output: Some(json!({"count": 42}).into()),
content: "42 results".to_string(),
is_error: false,
metadata: rskit_tool::ToolMetadata::new(),
};
let mcp_result = tool_result_to_call_result(&result);
assert_eq!(mcp_result.structured_content, Some(json!({"count": 42})));
}
#[test]
fn test_call_result_to_tool_result() {
let mcp_result = CallToolResult::success(vec![Content::text("result text")]);
let tool_result = call_result_to_tool_result(&mcp_result);
assert_eq!(tool_result.content, "result text");
assert!(!tool_result.is_error);
}
#[test]
fn test_call_result_error_to_tool_result() {
let mcp_result = CallToolResult::error(vec![Content::text("error msg")]);
let tool_result = call_result_to_tool_result(&mcp_result);
assert_eq!(tool_result.content, "error msg");
assert!(tool_result.is_error);
}
#[test]
fn test_definitions_to_list_result() {
let defs = vec![sample_definition()];
let result = definitions_to_list_result(&defs, "");
assert_eq!(result.tools.len(), 1);
assert_eq!(result.tools[0].name.as_ref(), "search");
}
#[test]
fn test_roundtrip_annotations_preserved() {
let def = sample_definition();
let tool = definition_to_tool(&def, "");
let round_tripped = tool_to_definition(&tool, "").unwrap();
let ann = round_tripped.annotations;
assert_eq!(ann.title, "Web Search");
assert_eq!(ann.idempotent_hint, Some(true));
assert_eq!(round_tripped.envelope.safety, Safety::ReadOnly);
}
#[test]
fn test_value_to_json_object_non_object() {
let obj = value_to_json_object(&json!(42));
assert_eq!(obj.get("type").and_then(|v| v.as_str()), Some("object"));
}
}