use crate::types::{CallToolResult, ToolAnnotations, ToolExecution, ToolInfo};
use crate::{Error, Result};
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::Value;
use std::fmt;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
use super::cancellation::RequestHandlerExtra;
use super::{ToolHandler, ToolOutput};
#[cfg(feature = "schema-generation")]
use schemars::JsonSchema;
#[cfg(feature = "validation")]
type GardeValidator<T> = fn(&T) -> std::result::Result<(), garde::Report>;
pub struct TypedTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
+ Send
+ Sync,
{
name: String,
description: Option<String>,
input_schema: Value,
annotations: Option<ToolAnnotations>,
ui_resource_uri: Option<String>,
execution: Option<ToolExecution>,
handler: F,
#[cfg(feature = "validation")]
validator: Option<GardeValidator<T>>,
_phantom: PhantomData<T>,
}
impl<T, F> fmt::Debug for TypedTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
+ Send
+ Sync,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypedTool")
.field("name", &self.name)
.field("description", &self.description)
.field("input_schema", &self.input_schema)
.field("annotations", &self.annotations)
.finish()
}
}
impl<T, F> TypedTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
+ Send
+ Sync,
{
#[cfg(feature = "schema-generation")]
pub fn new(name: impl Into<String>, handler: F) -> Self
where
T: JsonSchema,
{
let schema = generate_schema::<T>();
Self {
name: name.into(),
description: None,
input_schema: schema,
annotations: None,
ui_resource_uri: None,
execution: None,
handler,
#[cfg(feature = "validation")]
validator: None,
_phantom: PhantomData,
}
}
pub fn new_with_schema(name: impl Into<String>, schema: Value, handler: F) -> Self {
Self {
name: name.into(),
description: None,
input_schema: schema,
annotations: None,
ui_resource_uri: None,
execution: None,
handler,
#[cfg(feature = "validation")]
validator: None,
_phantom: PhantomData,
}
}
#[cfg(all(feature = "validation", feature = "schema-generation"))]
pub fn new_validated(name: impl Into<String>, handler: F) -> Self
where
T: JsonSchema + garde::Validate<Context = ()>,
{
Self::new(name, handler).with_garde_validator()
}
#[cfg(feature = "validation")]
pub fn new_validated_with_schema(name: impl Into<String>, schema: Value, handler: F) -> Self
where
T: garde::Validate<Context = ()>,
{
Self::new_with_schema(name, schema, handler).with_garde_validator()
}
#[cfg(feature = "validation")]
fn with_garde_validator(mut self) -> Self
where
T: garde::Validate<Context = ()>,
{
self.validator = Some(garde::Validate::validate);
self
}
#[cfg(feature = "validation")]
fn deserialize_args(&self, args: Value) -> Result<T> {
serde_json::from_value(args).map_err(|e| {
if self.validator.is_some() {
redacted_deserialize_error(&self.name, &e)
} else {
legacy_deserialize_error(&self.name, &e)
}
})
}
#[cfg(not(feature = "validation"))]
fn deserialize_args(&self, args: Value) -> Result<T> {
serde_json::from_value(args).map_err(|e| legacy_deserialize_error(&self.name, &e))
}
#[cfg(feature = "validation")]
fn run_garde(&self, typed_args: &T) -> Result<()> {
match self.validator {
Some(validate) => {
validate(typed_args).map_err(|report| render_garde_refusal(&self.name, &report))
},
None => Ok(()),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
self.annotations = Some(annotations);
self
}
pub fn read_only(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_read_only(true));
self
}
pub fn destructive(mut self) -> Self {
self.annotations = Some(
self.annotations
.unwrap_or_default()
.with_read_only(false)
.with_destructive(true),
);
self
}
pub fn idempotent(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_idempotent(true));
self
}
pub fn open_world(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_open_world(true));
self
}
pub fn with_ui(mut self, ui_resource_uri: impl Into<String>) -> Self {
self.ui_resource_uri = Some(ui_resource_uri.into());
self
}
pub fn with_execution(mut self, execution: ToolExecution) -> Self {
self.execution = Some(execution);
self
}
}
#[async_trait]
impl<T, F> ToolHandler for TypedTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
+ Send
+ Sync,
{
async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> {
let typed_args: T = self.deserialize_args(args)?;
#[cfg(feature = "validation")]
self.run_garde(&typed_args)?;
(self.handler)(typed_args, extra).await
}
fn metadata(&self) -> Option<ToolInfo> {
Some(ToolInfo {
name: self.name.clone(),
title: None,
description: self.description.clone(),
input_schema: self.input_schema.clone(),
output_schema: None,
annotations: self.annotations.clone(),
icons: None,
_meta: crate::types::ui::build_ui_meta(self.ui_resource_uri.as_deref()),
execution: self.execution.clone(),
})
}
}
fn legacy_deserialize_error(tool: &str, e: &serde_json::Error) -> Error {
Error::Validation(format!("Invalid arguments for tool '{}': {}", tool, e))
}
#[cfg(feature = "validation")]
fn redacted_deserialize_error(tool: &str, e: &serde_json::Error) -> Error {
let classification = match e.classify() {
serde_json::error::Category::Data => {
"the arguments do not match the declared argument type"
},
serde_json::error::Category::Syntax => "the arguments are not well-formed JSON",
serde_json::error::Category::Eof => "the arguments ended unexpectedly",
serde_json::error::Category::Io => "the arguments could not be read",
};
let position = if e.line() == 0 {
String::new()
} else {
format!(" at line {}, column {}", e.line(), e.column())
};
Error::Validation(format!(
"Invalid arguments for tool '{tool}': {classification}{position}"
))
}
#[cfg(feature = "validation")]
fn render_garde_refusal(tool: &str, report: &garde::Report) -> Error {
let detail = report
.iter()
.map(|(path, error)| {
let pointer = project_garde_path(path);
if pointer.is_empty() {
error.message().to_string()
} else {
format!("{pointer}: {}", error.message())
}
})
.collect::<Vec<String>>()
.join("; ");
let detail = if detail.is_empty() {
"the arguments do not satisfy the declared field rules".to_string()
} else {
detail
};
Error::Validation(format!("Invalid arguments for tool '{tool}': {detail}"))
}
#[cfg(feature = "validation")]
fn project_garde_path(path: &garde::Path) -> String {
let mut out = String::new();
for (_, component) in path.__iter().rev() {
out.push('/');
out.push_str(project_garde_segment(component.as_str()));
}
out
}
#[cfg(feature = "validation")]
fn project_garde_segment(segment: &str) -> &str {
if is_identifier_shaped(segment) || is_base10_index(segment) {
segment
} else {
crate::server::schema_validation::REDACTED_SEGMENT
}
}
#[cfg(feature = "validation")]
fn is_identifier_shaped(segment: &str) -> bool {
let mut bytes = segment.bytes();
match bytes.next() {
Some(first) if first.is_ascii_alphabetic() || first == b'_' => {},
_ => return false,
}
bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}
#[cfg(feature = "validation")]
fn is_base10_index(segment: &str) -> bool {
!segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit())
}
pub struct TypedSyncTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Result<Value> + Send + Sync,
{
name: String,
description: Option<String>,
input_schema: Value,
annotations: Option<ToolAnnotations>,
ui_resource_uri: Option<String>,
execution: Option<ToolExecution>,
handler: F,
#[cfg(feature = "validation")]
validator: Option<GardeValidator<T>>,
_phantom: PhantomData<T>,
}
impl<T, F> fmt::Debug for TypedSyncTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Result<Value> + Send + Sync,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypedSyncTool")
.field("name", &self.name)
.field("description", &self.description)
.field("input_schema", &self.input_schema)
.field("annotations", &self.annotations)
.finish()
}
}
impl<T, F> TypedSyncTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Result<Value> + Send + Sync,
{
#[cfg(feature = "schema-generation")]
pub fn new(name: impl Into<String>, handler: F) -> Self
where
T: JsonSchema,
{
let schema = generate_schema::<T>();
Self {
name: name.into(),
description: None,
input_schema: schema,
annotations: None,
ui_resource_uri: None,
execution: None,
handler,
#[cfg(feature = "validation")]
validator: None,
_phantom: PhantomData,
}
}
pub fn new_with_schema(name: impl Into<String>, schema: Value, handler: F) -> Self {
Self {
name: name.into(),
description: None,
input_schema: schema,
annotations: None,
ui_resource_uri: None,
execution: None,
handler,
#[cfg(feature = "validation")]
validator: None,
_phantom: PhantomData,
}
}
#[cfg(all(feature = "validation", feature = "schema-generation"))]
pub fn new_validated(name: impl Into<String>, handler: F) -> Self
where
T: JsonSchema + garde::Validate<Context = ()>,
{
Self::new(name, handler).with_garde_validator()
}
#[cfg(feature = "validation")]
pub fn new_validated_with_schema(name: impl Into<String>, schema: Value, handler: F) -> Self
where
T: garde::Validate<Context = ()>,
{
Self::new_with_schema(name, schema, handler).with_garde_validator()
}
#[cfg(feature = "validation")]
fn with_garde_validator(mut self) -> Self
where
T: garde::Validate<Context = ()>,
{
self.validator = Some(garde::Validate::validate);
self
}
#[cfg(feature = "validation")]
fn deserialize_args(&self, args: Value) -> Result<T> {
serde_json::from_value(args).map_err(|e| {
if self.validator.is_some() {
redacted_deserialize_error(&self.name, &e)
} else {
legacy_deserialize_error(&self.name, &e)
}
})
}
#[cfg(not(feature = "validation"))]
fn deserialize_args(&self, args: Value) -> Result<T> {
serde_json::from_value(args).map_err(|e| legacy_deserialize_error(&self.name, &e))
}
#[cfg(feature = "validation")]
fn run_garde(&self, typed_args: &T) -> Result<()> {
match self.validator {
Some(validate) => {
validate(typed_args).map_err(|report| render_garde_refusal(&self.name, &report))
},
None => Ok(()),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
self.annotations = Some(annotations);
self
}
pub fn read_only(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_read_only(true));
self
}
pub fn destructive(mut self) -> Self {
self.annotations = Some(
self.annotations
.unwrap_or_default()
.with_read_only(false)
.with_destructive(true),
);
self
}
pub fn idempotent(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_idempotent(true));
self
}
pub fn open_world(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_open_world(true));
self
}
pub fn with_ui(mut self, ui_resource_uri: impl Into<String>) -> Self {
self.ui_resource_uri = Some(ui_resource_uri.into());
self
}
pub fn with_execution(mut self, execution: ToolExecution) -> Self {
self.execution = Some(execution);
self
}
}
#[async_trait]
impl<T, F> ToolHandler for TypedSyncTool<T, F>
where
T: DeserializeOwned + Send + Sync + 'static,
F: Fn(T, RequestHandlerExtra) -> Result<Value> + Send + Sync,
{
async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> {
let typed_args: T = self.deserialize_args(args)?;
#[cfg(feature = "validation")]
self.run_garde(&typed_args)?;
(self.handler)(typed_args, extra)
}
fn metadata(&self) -> Option<ToolInfo> {
Some(ToolInfo {
name: self.name.clone(),
title: None,
description: self.description.clone(),
input_schema: self.input_schema.clone(),
output_schema: None,
annotations: self.annotations.clone(),
icons: None,
_meta: crate::types::ui::build_ui_meta(self.ui_resource_uri.as_deref()),
execution: self.execution.clone(),
})
}
}
#[cfg(feature = "schema-generation")]
fn generate_schema<T: JsonSchema>() -> Value {
let schema = schemars::schema_for!(T);
let json_schema = serde_json::to_value(&schema).unwrap_or_else(|_| {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
});
crate::server::schema_utils::normalize_schema(json_schema)
}
pub trait SimpleToolExt {
#[cfg(feature = "schema-generation")]
fn with_schema_from<T: JsonSchema>(self) -> Self;
}
use super::simple_tool::SimpleTool;
impl<F> SimpleToolExt for SimpleTool<F>
where
F: Fn(Value, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<Value>> + Send>>
+ Send
+ Sync,
{
#[cfg(feature = "schema-generation")]
fn with_schema_from<T: JsonSchema>(self) -> Self {
let schema = generate_schema::<T>();
self.with_schema(schema)
}
}
pub trait SyncToolExt {
#[cfg(feature = "schema-generation")]
fn with_schema_from<T: JsonSchema>(self) -> Self;
}
use super::simple_tool::SyncTool;
impl<F> SyncToolExt for SyncTool<F>
where
F: Fn(Value) -> Result<Value> + Send + Sync,
{
#[cfg(feature = "schema-generation")]
fn with_schema_from<T: JsonSchema>(self) -> Self {
let schema = generate_schema::<T>();
self.with_schema(schema)
}
}
pub struct TypedToolWithOutput<TIn, TOut, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
TOut: Serialize + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<TOut>> + Send>>
+ Send
+ Sync,
{
name: String,
description: Option<String>,
input_schema: Value,
output_schema: Option<Value>,
annotations: Option<ToolAnnotations>,
ui_resource_uri: Option<String>,
execution: Option<ToolExecution>,
handler: F,
_phantom: PhantomData<(TIn, TOut)>,
}
impl<TIn, TOut, F> fmt::Debug for TypedToolWithOutput<TIn, TOut, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
TOut: Serialize + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<TOut>> + Send>>
+ Send
+ Sync,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypedToolWithOutput")
.field("name", &self.name)
.field("description", &self.description)
.field("input_schema", &self.input_schema)
.field("output_schema", &self.output_schema)
.field("annotations", &self.annotations)
.field("ui_resource_uri", &self.ui_resource_uri)
.finish()
}
}
impl<TIn, TOut, F> TypedToolWithOutput<TIn, TOut, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
TOut: Serialize + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<TOut>> + Send>>
+ Send
+ Sync,
{
#[cfg(feature = "schema-generation")]
pub fn new(name: impl Into<String>, handler: F) -> Self
where
TIn: JsonSchema,
TOut: JsonSchema,
{
let input_schema = generate_schema::<TIn>();
let output_schema = Some(generate_schema::<TOut>());
Self {
name: name.into(),
description: None,
input_schema,
output_schema,
annotations: None,
ui_resource_uri: None,
execution: None,
handler,
_phantom: PhantomData,
}
}
#[cfg(feature = "schema-generation")]
pub fn new_input_only(name: impl Into<String>, handler: F) -> Self
where
TIn: JsonSchema,
{
let input_schema = generate_schema::<TIn>();
Self {
name: name.into(),
description: None,
input_schema,
output_schema: None,
annotations: None,
ui_resource_uri: None,
execution: None,
handler,
_phantom: PhantomData,
}
}
pub fn new_with_schemas(
name: impl Into<String>,
input_schema: Value,
output_schema: Option<Value>,
handler: F,
) -> Self {
Self {
name: name.into(),
description: None,
input_schema,
output_schema,
annotations: None,
ui_resource_uri: None,
execution: None,
handler,
_phantom: PhantomData,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
self.annotations = Some(annotations);
self
}
pub fn read_only(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_read_only(true));
self
}
pub fn destructive(mut self) -> Self {
self.annotations = Some(
self.annotations
.unwrap_or_default()
.with_read_only(false)
.with_destructive(true),
);
self
}
pub fn idempotent(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_idempotent(true));
self
}
pub fn open_world(mut self) -> Self {
self.annotations = Some(self.annotations.unwrap_or_default().with_open_world(true));
self
}
pub fn output_schema(&self) -> Option<&Value> {
self.output_schema.as_ref()
}
pub fn with_ui(mut self, ui_resource_uri: impl Into<String>) -> Self {
self.ui_resource_uri = Some(ui_resource_uri.into());
self
}
pub fn with_execution(mut self, execution: ToolExecution) -> Self {
self.execution = Some(execution);
self
}
}
#[async_trait]
impl<TIn, TOut, F> ToolHandler for TypedToolWithOutput<TIn, TOut, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
TOut: Serialize + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<TOut>> + Send>>
+ Send
+ Sync,
{
async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> {
let typed_args: TIn = serde_json::from_value(args)
.map_err(|e| Error::Validation(format!("Invalid arguments: {}", e)))?;
let result = (self.handler)(typed_args, extra).await?;
serde_json::to_value(result)
.map_err(|e| Error::Internal(format!("Failed to serialize result: {}", e)))
}
fn metadata(&self) -> Option<ToolInfo> {
let mut annotations = self.annotations.clone().unwrap_or_default();
if let Some(schema) = &self.output_schema {
if annotations.output_type_name.is_none() {
let type_name = schema
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("Output")
.to_string();
annotations = annotations.with_output_type_name(type_name);
}
}
let has_annotations = !annotations.is_empty();
Some(ToolInfo {
name: self.name.clone(),
title: None,
description: self.description.clone(),
input_schema: self.input_schema.clone(),
output_schema: self.output_schema.clone(),
annotations: if has_annotations {
Some(annotations)
} else {
None
},
icons: None,
_meta: crate::types::ui::build_ui_meta(self.ui_resource_uri.as_deref()),
execution: self.execution.clone(),
})
}
}
pub struct TypedToolWithResult<TIn, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<CallToolResult>> + Send>>
+ Send
+ Sync,
{
name: String,
description: Option<String>,
input_schema: Value,
handler: F,
_phantom: PhantomData<TIn>,
}
impl<TIn, F> fmt::Debug for TypedToolWithResult<TIn, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<CallToolResult>> + Send>>
+ Send
+ Sync,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypedToolWithResult")
.field("name", &self.name)
.field("description", &self.description)
.field("input_schema", &self.input_schema)
.finish()
}
}
impl<TIn, F> TypedToolWithResult<TIn, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<CallToolResult>> + Send>>
+ Send
+ Sync,
{
#[cfg(feature = "schema-generation")]
pub fn new(name: impl Into<String>, handler: F) -> Self
where
TIn: JsonSchema,
{
Self {
name: name.into(),
description: None,
input_schema: generate_schema::<TIn>(),
handler,
_phantom: PhantomData,
}
}
pub fn new_with_schema(name: impl Into<String>, input_schema: Value, handler: F) -> Self {
Self {
name: name.into(),
description: None,
input_schema,
handler,
_phantom: PhantomData,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
async fn run(&self, args: Value, extra: RequestHandlerExtra) -> Result<CallToolResult> {
let typed_args: TIn = serde_json::from_value(args).map_err(|e| {
Error::Validation(format!("Invalid arguments for tool '{}': {}", self.name, e))
})?;
(self.handler)(typed_args, extra).await
}
}
#[async_trait]
impl<TIn, F> ToolHandler for TypedToolWithResult<TIn, F>
where
TIn: DeserializeOwned + Send + Sync + 'static,
F: Fn(TIn, RequestHandlerExtra) -> Pin<Box<dyn Future<Output = Result<CallToolResult>> + Send>>
+ Send
+ Sync,
{
async fn handle(&self, args: Value, extra: RequestHandlerExtra) -> Result<Value> {
let result = self.run(args, extra).await?;
serde_json::to_value(result)
.map_err(|e| Error::Internal(format!("Failed to serialize CallToolResult: {}", e)))
}
async fn handle_output(&self, args: Value, extra: RequestHandlerExtra) -> Result<ToolOutput> {
Ok(ToolOutput::Result(self.run(args, extra).await?))
}
fn metadata(&self) -> Option<ToolInfo> {
Some(ToolInfo {
name: self.name.clone(),
title: None,
description: self.description.clone(),
input_schema: self.input_schema.clone(),
output_schema: None,
annotations: None,
icons: None,
_meta: None,
execution: None,
})
}
}
#[cfg(test)]
#[allow(clippy::used_underscore_binding)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_typed_tool_metadata_with_ui_has_standard_key_only() {
let tool = TypedTool::new_with_schema(
"test_tool",
json!({"type": "object"}),
|_args: serde_json::Value, _extra| Box::pin(async { Ok(json!({})) }),
)
.with_ui("ui://widgets/chart.html");
let info = tool.metadata().unwrap();
let meta = info._meta.as_ref().expect("_meta should be present");
let ui_obj = meta.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://widgets/chart.html");
assert!(
meta.get("openai/outputTemplate").is_none(),
"must NOT have openai/outputTemplate in standard-only mode"
);
}
#[test]
fn test_typed_tool_metadata_without_ui_has_no_meta() {
let tool = TypedTool::new_with_schema(
"test_tool",
json!({"type": "object"}),
|_args: serde_json::Value, _extra| Box::pin(async { Ok(json!({})) }),
);
let info = tool.metadata().unwrap();
assert!(info._meta.is_none(), "_meta should be None without UI");
}
#[test]
fn test_typed_sync_tool_metadata_with_ui_has_standard_key_only() {
let tool = TypedSyncTool::new_with_schema(
"test_sync_tool",
json!({"type": "object"}),
|_args: serde_json::Value, _extra| Ok(json!({})),
)
.with_ui("ui://widgets/chart.html");
let info = tool.metadata().unwrap();
let meta = info._meta.as_ref().expect("_meta should be present");
let ui_obj = meta.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://widgets/chart.html");
assert!(
meta.get("openai/outputTemplate").is_none(),
"must NOT have openai/outputTemplate in standard-only mode"
);
}
#[test]
fn test_typed_sync_tool_metadata_without_ui_has_no_meta() {
let tool = TypedSyncTool::new_with_schema(
"test_sync_tool",
json!({"type": "object"}),
|_args: serde_json::Value, _extra| Ok(json!({})),
);
let info = tool.metadata().unwrap();
assert!(info._meta.is_none(), "_meta should be None without UI");
}
#[test]
fn test_typed_tool_with_output_with_ui_metadata() {
let tool = TypedToolWithOutput::new_with_schemas(
"ui_output_tool",
json!({"type": "object"}),
None,
|_args: serde_json::Value, _extra: RequestHandlerExtra| {
Box::pin(async { Ok(json!({"result": "ok"})) })
},
)
.with_ui("ui://widgets/dashboard.html");
let info = tool.metadata().unwrap();
let meta = info._meta.as_ref().expect("_meta should be present");
let ui_obj = meta.get("ui").expect("must have nested 'ui' key");
assert_eq!(ui_obj["resourceUri"], "ui://widgets/dashboard.html");
assert!(
meta.get("openai/outputTemplate").is_none(),
"must NOT have openai/outputTemplate in standard-only mode"
);
}
#[test]
fn test_typed_tool_with_output_with_ui_and_output_schema_coexist() {
let output_schema = json!({
"type": "object",
"title": "DashboardResult",
"properties": {
"data": { "type": "array" }
}
});
let tool = TypedToolWithOutput::new_with_schemas(
"ui_schema_tool",
json!({"type": "object"}),
Some(output_schema),
|_args: serde_json::Value, _extra: RequestHandlerExtra| {
Box::pin(async { Ok(json!({"data": []})) })
},
)
.with_ui("ui://charts/bar.html");
let info = tool.metadata().unwrap();
let meta = info
._meta
.as_ref()
.expect("_meta should be present with UI");
assert!(meta.get("ui").is_some(), "ui key must be present");
assert!(
meta.get("openai/outputTemplate").is_none(),
"must NOT have openai/outputTemplate in standard-only mode"
);
assert!(
info.output_schema.is_some(),
"output_schema must be present on ToolInfo"
);
let annotations = info
.annotations
.as_ref()
.expect("annotations should be present");
assert!(
annotations.output_type_name.is_some(),
"output_type_name annotation must be present"
);
}
#[test]
fn test_typed_tool_with_output_without_ui_has_no_meta() {
let tool = TypedToolWithOutput::new_with_schemas(
"no_ui_tool",
json!({"type": "object"}),
None,
|_args: serde_json::Value, _extra: RequestHandlerExtra| {
Box::pin(async { Ok(json!({})) })
},
);
let info = tool.metadata().unwrap();
assert!(info._meta.is_none(), "_meta should be None without UI");
}
#[test]
fn test_typed_tool_with_execution_metadata() {
use crate::types::{TaskSupport, ToolExecution};
let tool = TypedTool::new_with_schema(
"long_task",
json!({"type": "object"}),
|_args: serde_json::Value, _extra| Box::pin(async { Ok(json!({})) }),
)
.with_execution(ToolExecution::new().with_task_support(TaskSupport::Required));
let info = tool.metadata().unwrap();
let exec = info
.execution
.as_ref()
.expect("execution should be present");
assert_eq!(exec.task_support, Some(TaskSupport::Required));
}
#[test]
fn test_typed_tool_without_execution_returns_none() {
let tool = TypedTool::new_with_schema(
"simple_tool",
json!({"type": "object"}),
|_args: serde_json::Value, _extra| Box::pin(async { Ok(json!({})) }),
);
let info = tool.metadata().unwrap();
assert!(info.execution.is_none());
}
}