use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::content::Content;
use super::protocol::Cursor;
use super::protocol::RequestMeta;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct ToolAnnotations {
#[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>,
#[serde(
rename = "pmcp:outputTypeName",
skip_serializing_if = "Option::is_none"
)]
pub output_type_name: Option<String>,
}
impl ToolAnnotations {
pub fn new() -> Self {
Self::default()
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_read_only(mut self, read_only: bool) -> Self {
self.read_only_hint = Some(read_only);
self
}
pub fn with_destructive(mut self, destructive: bool) -> Self {
self.destructive_hint = Some(destructive);
self
}
pub fn with_idempotent(mut self, idempotent: bool) -> Self {
self.idempotent_hint = Some(idempotent);
self
}
pub fn with_open_world(mut self, open_world: bool) -> Self {
self.open_world_hint = Some(open_world);
self
}
pub fn with_output_type_name(mut self, name: impl Into<String>) -> Self {
self.output_type_name = Some(name.into());
self
}
pub fn is_empty(&self) -> bool {
self.title.is_none()
&& self.read_only_hint.is_none()
&& self.destructive_hint.is_none()
&& self.idempotent_hint.is_none()
&& self.open_world_hint.is_none()
&& self.output_type_name.is_none()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct ToolExecution {
#[serde(skip_serializing_if = "Option::is_none")]
pub task_support: Option<TaskSupport>,
}
impl ToolExecution {
pub fn new() -> Self {
Self::default()
}
pub fn with_task_support(mut self, support: TaskSupport) -> Self {
self.task_support = Some(support);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TaskSupport {
Required,
Optional,
Forbidden,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct ToolInfo {
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: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<ToolAnnotations>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<super::protocol::IconInfo>>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none", default)]
#[allow(clippy::pub_underscore_fields)] pub _meta: Option<serde_json::Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub execution: Option<ToolExecution>,
}
impl ToolInfo {
pub fn new(name: impl Into<String>, description: Option<String>, input_schema: Value) -> Self {
Self {
name: name.into(),
title: None,
description,
input_schema,
output_schema: None,
annotations: None,
icons: None,
_meta: None,
execution: None,
}
}
pub fn with_annotations(
name: impl Into<String>,
description: Option<String>,
input_schema: Value,
annotations: ToolAnnotations,
) -> Self {
Self {
name: name.into(),
title: None,
description,
input_schema,
output_schema: None,
annotations: Some(annotations),
icons: None,
_meta: None,
execution: None,
}
}
pub fn with_ui(
name: impl Into<String>,
description: Option<String>,
input_schema: Value,
ui_resource_uri: impl Into<String>,
) -> Self {
let uri: String = ui_resource_uri.into();
let meta = crate::types::ui::ToolUIMetadata::build_meta_map(&uri);
Self {
name: name.into(),
title: None,
description,
input_schema,
output_schema: None,
annotations: None,
icons: None,
_meta: Some(meta),
execution: None,
}
}
pub fn with_output_schema(mut self, schema: serde_json::Value) -> Self {
self.output_schema = Some(schema);
self
}
#[cfg(feature = "mcp-apps")]
#[allow(clippy::used_underscore_binding, clippy::needless_pass_by_value)]
pub fn with_widget_meta(mut self, widget: crate::types::mcp_apps::WidgetMeta) -> Self {
let meta = self._meta.get_or_insert_with(serde_json::Map::new);
let overlay = widget.to_meta_map();
crate::types::ui::deep_merge(meta, overlay);
self
}
#[allow(clippy::used_underscore_binding)]
pub fn with_meta_entry(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
let meta = self._meta.get_or_insert_with(serde_json::Map::new);
let mut overlay = serde_json::Map::with_capacity(1);
overlay.insert(key.into(), value);
crate::types::ui::deep_merge(meta, overlay);
self
}
#[allow(clippy::used_underscore_binding)]
pub fn widget_meta(&self) -> Option<&serde_json::Map<String, Value>> {
self._meta.as_ref().filter(|meta| {
meta.contains_key("openai/outputTemplate")
|| meta.contains_key(crate::types::ui::META_KEY_UI_RESOURCE_URI)
|| meta.get("ui").and_then(|v| v.get("resourceUri")).is_some()
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListToolsRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Cursor,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListToolsResult {
pub tools: Vec<ToolInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Cursor,
}
impl ListToolsResult {
pub fn new(tools: Vec<ToolInfo>) -> Self {
Self {
tools,
next_cursor: None,
}
}
pub fn with_next_cursor(mut self, cursor: impl Into<String>) -> Self {
self.next_cursor = Some(cursor.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct CallToolRequest {
pub name: String,
#[serde(default)]
pub arguments: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[allow(clippy::pub_underscore_fields)] pub _meta: Option<RequestMeta>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub task: Option<Value>,
}
impl CallToolRequest {
pub fn new(name: impl Into<String>, arguments: Value) -> Self {
Self {
name: name.into(),
arguments,
_meta: None,
task: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct CallToolResult {
#[serde(default)]
pub content: Vec<Content>,
#[serde(default)]
pub is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<Value>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
#[allow(clippy::pub_underscore_fields)] pub _meta: Option<serde_json::Map<String, Value>>,
}
impl CallToolResult {
pub fn new(content: Vec<Content>) -> Self {
Self {
content,
is_error: false,
structured_content: None,
_meta: None,
}
}
pub fn error(content: Vec<Content>) -> Self {
Self {
content,
is_error: true,
structured_content: None,
_meta: None,
}
}
pub fn with_structured_content(mut self, content: Value) -> Self {
self.structured_content = Some(content);
self
}
#[allow(clippy::used_underscore_binding)] pub fn with_meta(mut self, meta: serde_json::Map<String, Value>) -> Self {
self._meta = Some(meta);
self
}
pub fn with_widget_enrichment(self, info: &ToolInfo, structured_value: Value) -> Self {
if let Some(meta) = info.widget_meta() {
let enriched = self.with_structured_content(structured_value);
let filtered: serde_json::Map<String, Value> = meta
.iter()
.filter(|(k, _)| k.starts_with("openai/toolInvocation/"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if !filtered.is_empty() {
enriched.with_meta(filtered)
} else {
enriched
}
} else {
self
}
}
}
#[cfg(test)]
#[allow(clippy::used_underscore_binding)] mod tests {
use super::*;
use serde_json::json;
#[test]
fn tool_info_serialization() {
let tool = ToolInfo::new(
"test-tool",
Some("A test tool".to_string()),
json!({
"type": "object",
"properties": {
"param": {"type": "string"}
}
}),
);
let json = serde_json::to_value(&tool).unwrap();
assert_eq!(json["name"], "test-tool");
assert_eq!(json["description"], "A test tool");
assert_eq!(json["inputSchema"]["type"], "object");
}
#[test]
fn test_call_tool_result_basic() {
let result = CallToolResult::new(vec![Content::Text {
text: "Move accepted".to_string(),
}]);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["content"][0]["text"], "Move accepted");
assert_eq!(json["isError"], false);
assert!(json.get("structuredContent").is_none());
assert!(json.get("_meta").is_none());
}
#[test]
fn test_call_tool_result_with_structured_content() {
let result = CallToolResult::new(vec![Content::Text {
text: "Move e2-e4 played".to_string(),
}])
.with_structured_content(json!({
"boardState": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR",
"lastMove": { "from": "e2", "to": "e4" }
}));
let json = serde_json::to_value(&result).unwrap();
assert_eq!(
json["structuredContent"]["boardState"],
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR"
);
assert_eq!(json["structuredContent"]["lastMove"]["from"], "e2");
assert_eq!(json["structuredContent"]["lastMove"]["to"], "e4");
}
#[test]
fn test_call_tool_result_with_meta() {
let mut meta = serde_json::Map::new();
meta.insert("widgetState".to_string(), json!({ "selectedSquare": "e4" }));
meta.insert("displayHints".to_string(), json!({ "animate": true }));
let result = CallToolResult::new(vec![]).with_meta(meta);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["_meta"]["widgetState"]["selectedSquare"], "e4");
assert_eq!(json["_meta"]["displayHints"]["animate"], true);
}
#[test]
fn test_call_tool_result_full_three_tier() {
let mut meta = serde_json::Map::new();
meta.insert("widgetState".to_string(), json!({ "theme": "dark" }));
let result = CallToolResult::new(vec![Content::Text {
text: "Chess game started. White to move.".to_string(),
}])
.with_structured_content(json!({
"fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"turn": "white",
"legalMoves": ["e2e4", "d2d4", "Nf3", "Nc3"]
}))
.with_meta(meta);
let json = serde_json::to_value(&result).unwrap();
assert!(json["content"][0]["text"]
.as_str()
.unwrap()
.contains("Chess game started"));
assert_eq!(json["structuredContent"]["turn"], "white");
assert_eq!(json["_meta"]["widgetState"]["theme"], "dark");
}
#[test]
fn test_call_tool_result_error() {
let result = CallToolResult::error(vec![Content::Text {
text: "Invalid move: e2-e5 is not legal".to_string(),
}]);
assert!(result.is_error);
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["isError"], true);
}
#[test]
fn test_call_tool_result_deserialization() {
let json_str = r#"{
"content": [{"type": "text", "text": "Move played"}],
"isError": false,
"structuredContent": {"position": "e4"},
"_meta": {"widgetState": {"selected": true}}
}"#;
let result: CallToolResult = serde_json::from_str(json_str).unwrap();
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
assert!(result.structured_content.is_some());
assert!(result._meta.is_some());
let meta_value = result._meta.unwrap();
assert_eq!(meta_value["widgetState"]["selected"], true);
}
#[test]
fn test_call_tool_request_with_task() {
let json_str = r#"{"name": "my_tool", "arguments": {}, "task": {"ttl": 60000}}"#;
let req: CallToolRequest = serde_json::from_str(json_str).unwrap();
assert!(req.task.is_some());
assert_eq!(req.task.unwrap()["ttl"], 60000);
}
#[test]
fn test_call_tool_request_without_task_backward_compat() {
let json_str = r#"{"name": "my_tool", "arguments": {}}"#;
let req: CallToolRequest = serde_json::from_str(json_str).unwrap();
assert!(req.task.is_none());
assert_eq!(req.name, "my_tool");
}
#[test]
fn test_tool_info_with_execution() {
let mut tool = ToolInfo::new(
"task-tool",
Some("A task-enabled tool".to_string()),
json!({"type": "object"}),
);
tool.execution = Some(ToolExecution::new().with_task_support(TaskSupport::Required));
let json = serde_json::to_value(&tool).unwrap();
assert_eq!(json["name"], "task-tool");
assert_eq!(json["execution"]["taskSupport"], "required");
}
#[test]
fn test_tool_execution_serialization() {
let exec = ToolExecution::new().with_task_support(TaskSupport::Required);
let json = serde_json::to_value(&exec).unwrap();
assert_eq!(json["taskSupport"], "required");
let exec2 = ToolExecution::new().with_task_support(TaskSupport::Optional);
let json2 = serde_json::to_value(&exec2).unwrap();
assert_eq!(json2["taskSupport"], "optional");
let exec3 = ToolExecution::new().with_task_support(TaskSupport::Forbidden);
let json3 = serde_json::to_value(&exec3).unwrap();
assert_eq!(json3["taskSupport"], "forbidden");
}
#[test]
fn test_tool_info_without_execution_omits_field() {
let tool = ToolInfo::new(
"normal-tool",
Some("A normal tool".to_string()),
json!({"type": "object"}),
);
let json = serde_json::to_value(&tool).unwrap();
assert!(json.get("execution").is_none());
}
#[test]
fn test_tool_info_with_ui_dual_format() {
let tool = ToolInfo::with_ui("my_tool", None, json!({"type": "object"}), "ui://w/x.html");
let meta = tool._meta.as_ref().unwrap();
let ui_obj = meta.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://w/x.html");
assert_eq!(
meta.get("ui/resourceUri"),
Some(&serde_json::Value::String("ui://w/x.html".to_string())),
"must have legacy flat ui/resourceUri key for Claude Desktop/ChatGPT"
);
}
#[test]
fn test_tool_info_with_ui_no_openai_keys() {
let tool = ToolInfo::with_ui("my_tool", None, json!({"type": "object"}), "ui://w/x.html");
let meta = tool._meta.as_ref().unwrap();
assert!(
meta.get("openai/outputTemplate").is_none(),
"must NOT have openai/outputTemplate in standard-only mode"
);
assert_eq!(
meta.len(),
2,
"_meta should have exactly 2 keys (ui + ui/resourceUri)"
);
}
#[test]
fn test_with_meta_entry_on_empty_meta() {
let tool = ToolInfo::new("t", None, json!({"type": "object"}))
.with_meta_entry("ui", json!({"resourceUri": "ui://x"}));
let meta = tool._meta.unwrap();
assert_eq!(meta["ui"]["resourceUri"], "ui://x");
}
#[test]
fn test_with_meta_entry_merges_with_existing() {
let mut initial = serde_json::Map::new();
initial.insert("ui".into(), json!({"resourceUri": "ui://x"}));
let tool = ToolInfo::new("t", None, json!({"type": "object"}));
let tool = ToolInfo {
_meta: Some(initial),
..tool
};
let tool = tool.with_meta_entry("execution", json!({"mode": "async"}));
let meta = tool._meta.unwrap();
assert_eq!(meta["ui"]["resourceUri"], "ui://x");
assert_eq!(meta["execution"]["mode"], "async");
}
#[test]
fn test_with_meta_entry_deep_merges_nested() {
let mut initial = serde_json::Map::new();
initial.insert("ui".into(), json!({"resourceUri": "ui://x"}));
let tool = ToolInfo::new("t", None, json!({"type": "object"}));
let tool = ToolInfo {
_meta: Some(initial),
..tool
};
let tool = tool.with_meta_entry("ui", json!({"prefersBorder": true}));
let meta = tool._meta.unwrap();
assert_eq!(meta["ui"]["resourceUri"], "ui://x");
assert_eq!(meta["ui"]["prefersBorder"], true);
}
#[test]
fn test_with_meta_entry_chained() {
let tool = ToolInfo::new("t", None, json!({"type": "object"}))
.with_meta_entry("a", json!(1))
.with_meta_entry("b", json!(2));
let meta = tool._meta.unwrap();
assert_eq!(meta["a"], 1);
assert_eq!(meta["b"], 2);
}
#[test]
fn test_existing_with_meta_replace_all_unchanged() {
let tool = ToolInfo::with_ui("t", None, json!({"type": "object"}), "ui://y");
let meta = tool._meta.unwrap();
assert_eq!(meta["ui"]["resourceUri"], "ui://y");
assert!(!meta.contains_key("openai/outputTemplate"));
}
#[test]
#[cfg(feature = "mcp-apps")]
fn test_with_widget_meta_merges_with_ui() {
use crate::types::mcp_apps::WidgetMeta;
let tool = ToolInfo::with_ui("t", None, json!({"type": "object"}), "ui://w/app.html")
.with_widget_meta(WidgetMeta::new().prefers_border(true).domain("x.com"));
let meta = tool._meta.unwrap();
assert_eq!(meta["ui"]["resourceUri"], "ui://w/app.html");
assert_eq!(meta["ui/resourceUri"], "ui://w/app.html");
assert!(!meta.contains_key("openai/outputTemplate"));
assert_eq!(meta["ui"]["prefersBorder"], true);
assert_eq!(meta["ui"]["domain"], "x.com");
assert_eq!(meta["openai/widgetPrefersBorder"], true);
assert_eq!(meta["openai/widgetDomain"], "x.com");
}
#[test]
#[cfg(feature = "mcp-apps")]
fn test_with_widget_meta_on_empty_meta() {
use crate::types::mcp_apps::WidgetMeta;
let tool = ToolInfo::new("t", None, json!({"type": "object"})).with_widget_meta(
WidgetMeta::new()
.resource_uri("ui://w/app.html")
.prefers_border(true),
);
let meta = tool._meta.unwrap();
assert_eq!(meta["ui"]["resourceUri"], "ui://w/app.html");
assert_eq!(meta["ui"]["prefersBorder"], true);
assert_eq!(meta["ui/resourceUri"], "ui://w/app.html");
assert!(!meta.contains_key("openai/outputTemplate"));
assert_eq!(meta["openai/widgetPrefersBorder"], true);
}
}