use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{Map, Value};
use crate::error::{Error, Result};
pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> Value;
async fn invoke(&self, arguments: Value) -> Result<Value>;
async fn invoke_in_context(
&self,
arguments: Value,
_ctx: &crate::middleware::FunctionInvocationContext,
) -> Result<Value> {
self.invoke(arguments).await
}
}
#[async_trait]
pub trait ToolSource: Send + Sync {
async fn resolve_tools(&self) -> Result<Vec<ToolDefinition>>;
fn source_name(&self) -> &str;
}
#[derive(Debug, Clone, PartialEq)]
pub enum ToolKind {
Function,
HostedCodeInterpreter,
HostedImageGeneration,
HostedWebSearch,
HostedFileSearch { max_results: Option<u32> },
HostedMcp {
url: String,
allowed_tools: Option<Vec<String>>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ApprovalMode {
#[default]
NeverRequire,
AlwaysRequire,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpApprovalMode {
Always,
Never,
PerTool {
always: Vec<String>,
never: Vec<String>,
},
}
impl McpApprovalMode {
fn into_value(self) -> Value {
match self {
McpApprovalMode::Always => Value::String("always_require".to_string()),
McpApprovalMode::Never => Value::String("never_require".to_string()),
McpApprovalMode::PerTool { always, never } => {
let mut map = Map::new();
if !always.is_empty() {
map.insert("always".to_string(), serde_json::json!(always));
}
if !never.is_empty() {
map.insert("never".to_string(), serde_json::json!(never));
}
Value::Object(map)
}
}
}
}
#[derive(Clone)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: Value,
pub kind: ToolKind,
pub approval_mode: ApprovalMode,
pub executor: Option<Arc<dyn Tool>>,
}
impl std::fmt::Debug for ToolDefinition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ToolDefinition")
.field("name", &self.name)
.field("description", &self.description)
.field("kind", &self.kind)
.field("approval_mode", &self.approval_mode)
.field("executable", &self.executor.is_some())
.finish()
}
}
impl ToolDefinition {
pub fn is_executable(&self) -> bool {
self.executor.is_some() && self.kind == ToolKind::Function
}
pub fn requires_approval(&self) -> bool {
self.approval_mode == ApprovalMode::AlwaysRequire
}
pub fn with_approval_mode(mut self, mode: ApprovalMode) -> Self {
self.approval_mode = mode;
self
}
pub fn require_approval(self) -> Self {
self.with_approval_mode(ApprovalMode::AlwaysRequire)
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn user_location(self, location: Value) -> Self {
self.set_param("user_location", location)
}
pub fn max_uses(self, max_uses: u32) -> Self {
self.set_param("max_uses", serde_json::json!(max_uses))
}
pub fn connection_id(self, connection_id: impl Into<String>) -> Self {
self.set_param("connection_id", Value::String(connection_id.into()))
}
pub fn custom_connection(
self,
connection_id: impl Into<String>,
instance_name: impl Into<String>,
) -> Self {
self.set_param("custom_connection_id", Value::String(connection_id.into()))
.set_param("instance_name", Value::String(instance_name.into()))
}
pub fn vector_store_ids(self, ids: Vec<String>) -> Self {
self.set_param("vector_store_ids", serde_json::json!(ids))
}
pub fn max_results(self, max_results: u32) -> Self {
self.set_param("max_results", serde_json::json!(max_results))
}
pub fn file_ids(self, file_ids: Vec<String>) -> Self {
self.set_param("file_ids", serde_json::json!(file_ids))
}
pub fn container(self, container: Value) -> Self {
self.set_param("container", container)
}
pub fn headers(self, headers: HashMap<String, String>) -> Self {
self.set_param("headers", serde_json::json!(headers))
}
pub fn mcp_approval_mode(self, mode: McpApprovalMode) -> Self {
self.set_param("approval_mode", mode.into_value())
}
fn set_param(mut self, key: &str, value: Value) -> Self {
if !self.parameters.is_object() {
self.parameters = Value::Object(Map::new());
}
if let Value::Object(map) = &mut self.parameters {
map.insert(key.to_string(), value);
}
self
}
pub fn to_openai_spec(&self) -> Value {
serde_json::json!({
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
})
}
pub fn from_tool(tool: Arc<dyn Tool>) -> Self {
Self {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: tool.parameters_schema(),
kind: ToolKind::Function,
approval_mode: ApprovalMode::NeverRequire,
executor: Some(tool),
}
}
}
impl<T: Tool + 'static> From<Arc<T>> for ToolDefinition {
fn from(tool: Arc<T>) -> Self {
ToolDefinition::from_tool(tool)
}
}
type ToolClosure = Arc<dyn Fn(Value) -> BoxFuture<Result<Value>> + Send + Sync>;
#[derive(Clone)]
pub struct FunctionTool {
name: String,
description: String,
parameters: Value,
approval_mode: ApprovalMode,
func: ToolClosure,
max_invocations: Option<usize>,
max_invocation_exceptions: Option<usize>,
invocation_count: Arc<AtomicUsize>,
invocation_exception_count: Arc<AtomicUsize>,
}
impl FunctionTool {
pub fn new<F, Fut>(
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
func: F,
) -> Self
where
F: Fn(Value) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Value>> + Send + 'static,
{
Self {
name: name.into(),
description: description.into(),
parameters,
approval_mode: ApprovalMode::NeverRequire,
func: Arc::new(move |args| Box::pin(func(args))),
max_invocations: None,
max_invocation_exceptions: None,
invocation_count: Arc::new(AtomicUsize::new(0)),
invocation_exception_count: Arc::new(AtomicUsize::new(0)),
}
}
pub fn typed<Args, Ret, F, Fut>(
name: impl Into<String>,
description: impl Into<String>,
f: F,
) -> Self
where
Args: DeserializeOwned + schemars::JsonSchema + Send + 'static,
Ret: Serialize,
F: Fn(Args) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Ret>> + Send + 'static,
{
let name = name.into();
let parameters = typed_parameters_schema::<Args>();
let err_name = name.clone();
let f = Arc::new(f);
let func: ToolClosure = Arc::new(move |value: Value| {
let f = Arc::clone(&f);
let err_name = err_name.clone();
Box::pin(async move {
let args: Args = serde_json::from_value(value).map_err(|e| {
Error::tool(format!("invalid arguments for tool '{err_name}': {e}"))
})?;
let ret = f(args).await?;
serde_json::to_value(ret).map_err(|e| {
Error::tool(format!(
"failed to serialize result of tool '{err_name}': {e}"
))
})
})
});
Self {
name,
description: description.into(),
parameters,
approval_mode: ApprovalMode::NeverRequire,
func,
max_invocations: None,
max_invocation_exceptions: None,
invocation_count: Arc::new(AtomicUsize::new(0)),
invocation_exception_count: Arc::new(AtomicUsize::new(0)),
}
}
pub fn with_approval_mode(mut self, mode: ApprovalMode) -> Self {
self.approval_mode = mode;
self
}
pub fn max_invocations(mut self, max: usize) -> Self {
self.max_invocations = Some(max);
self
}
pub fn max_invocation_exceptions(mut self, max: usize) -> Self {
self.max_invocation_exceptions = Some(max);
self
}
pub fn invocation_count(&self) -> usize {
self.invocation_count.load(Ordering::SeqCst)
}
pub fn invocation_exception_count(&self) -> usize {
self.invocation_exception_count.load(Ordering::SeqCst)
}
pub fn into_definition(self) -> ToolDefinition {
let approval_mode = self.approval_mode;
ToolDefinition::from_tool(Arc::new(self)).with_approval_mode(approval_mode)
}
}
#[async_trait]
impl Tool for FunctionTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> Value {
self.parameters.clone()
}
async fn invoke(&self, arguments: Value) -> Result<Value> {
let invocation_limit_error = || {
Error::tool(format!(
"Function '{}' has reached its maximum invocation limit, \
you can no longer use this tool.",
self.name
))
};
if let Some(max) = self.max_invocations {
if self.invocation_count.load(Ordering::SeqCst) >= max {
return Err(invocation_limit_error());
}
}
if let Some(max) = self.max_invocation_exceptions {
if self.invocation_exception_count.load(Ordering::SeqCst) >= max {
return Err(Error::tool(format!(
"Function '{}' has reached its maximum exception limit, \
you tried to use this tool too many times and it kept failing.",
self.name
)));
}
}
match self.max_invocations {
Some(max) => {
if self
.invocation_count
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |c| {
(c < max).then(|| c + 1)
})
.is_err()
{
return Err(invocation_limit_error());
}
}
None => {
self.invocation_count.fetch_add(1, Ordering::SeqCst);
}
}
let result = (self.func)(arguments).await;
if result.is_err() {
self.invocation_exception_count
.fetch_add(1, Ordering::SeqCst);
}
result
}
}
pub fn hosted_code_interpreter() -> ToolDefinition {
ToolDefinition {
name: "code_interpreter".into(),
description: String::new(),
parameters: empty_schema(),
kind: ToolKind::HostedCodeInterpreter,
approval_mode: ApprovalMode::NeverRequire,
executor: None,
}
}
pub fn hosted_image_generation() -> ToolDefinition {
ToolDefinition {
name: "image_generation".into(),
description: String::new(),
parameters: empty_schema(),
kind: ToolKind::HostedImageGeneration,
approval_mode: ApprovalMode::NeverRequire,
executor: None,
}
}
pub fn hosted_web_search() -> ToolDefinition {
ToolDefinition {
name: "web_search".into(),
description: String::new(),
parameters: empty_schema(),
kind: ToolKind::HostedWebSearch,
approval_mode: ApprovalMode::NeverRequire,
executor: None,
}
}
pub fn hosted_file_search(max_results: Option<u32>) -> ToolDefinition {
ToolDefinition {
name: "file_search".into(),
description: String::new(),
parameters: empty_schema(),
kind: ToolKind::HostedFileSearch { max_results },
approval_mode: ApprovalMode::NeverRequire,
executor: None,
}
}
pub fn hosted_mcp(
name: impl Into<String>,
url: impl Into<String>,
allowed_tools: Option<Vec<String>>,
) -> ToolDefinition {
ToolDefinition {
name: name.into(),
description: String::new(),
parameters: empty_schema(),
kind: ToolKind::HostedMcp {
url: url.into(),
allowed_tools,
},
approval_mode: ApprovalMode::NeverRequire,
executor: None,
}
}
pub fn empty_schema() -> Value {
serde_json::json!({ "type": "object", "properties": {} })
}
fn typed_parameters_schema<Args: schemars::JsonSchema>() -> Value {
let root = schemars::gen::SchemaGenerator::default().into_root_schema_for::<Args>();
let mut value = serde_json::to_value(root).unwrap_or_else(|_| empty_schema());
if let Value::Object(map) = &mut value {
map.remove("$schema");
map.remove("title");
}
value
}
#[derive(Debug, Clone)]
pub struct FunctionInvocationConfig {
pub enabled: bool,
pub max_iterations: usize,
pub max_consecutive_errors_per_request: usize,
pub terminate_on_unknown_calls: bool,
pub include_detailed_errors: bool,
}
impl Default for FunctionInvocationConfig {
fn default() -> Self {
Self {
enabled: true,
max_iterations: 40,
max_consecutive_errors_per_request: 3,
terminate_on_unknown_calls: false,
include_detailed_errors: false,
}
}
}
impl FunctionInvocationConfig {
pub fn validate(&self) -> Result<()> {
if self.max_iterations < 1 {
return Err(Error::Configuration("max_iterations must be >= 1".into()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct WeatherArgs {
city: String,
#[serde(default)]
units: Option<String>,
}
#[test]
fn typed_schema_required_and_optional_fields_exact_json() {
let schema = typed_parameters_schema::<WeatherArgs>();
assert_eq!(
schema,
serde_json::json!({
"type": "object",
"properties": {
"city": { "type": "string" },
"units": { "type": ["string", "null"], "default": null },
},
"required": ["city"],
})
);
}
#[test]
fn typed_schema_strips_schema_and_title_keys() {
let schema = typed_parameters_schema::<WeatherArgs>();
let obj = schema.as_object().expect("object schema");
assert!(!obj.contains_key("$schema"));
assert!(!obj.contains_key("title"));
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
enum Priority {
Low,
High,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
struct Address {
city: String,
zip: Option<String>,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
struct TaskArgs {
title: String,
address: Address,
priority: Priority,
}
#[test]
fn typed_schema_nested_struct_and_enum_keep_schemars_ref_definitions() {
let schema = typed_parameters_schema::<TaskArgs>();
assert_eq!(
schema,
serde_json::json!({
"type": "object",
"definitions": {
"Address": {
"type": "object",
"properties": {
"city": { "type": "string" },
"zip": { "type": ["string", "null"] },
},
"required": ["city"],
},
"Priority": {
"type": "string",
"enum": ["Low", "High"],
},
},
"properties": {
"title": { "type": "string" },
"address": { "$ref": "#/definitions/Address" },
"priority": { "$ref": "#/definitions/Priority" },
},
"required": ["address", "priority", "title"],
})
);
}
#[tokio::test]
async fn typed_invoke_deserializes_valid_arguments_and_serializes_result() {
let tool = FunctionTool::typed(
"get_weather",
"Get the weather.",
|args: WeatherArgs| async move {
Ok(serde_json::json!({ "city": args.city, "units": args.units }))
},
);
let result = tool
.invoke(serde_json::json!({ "city": "Seattle" }))
.await
.unwrap();
assert_eq!(
result,
serde_json::json!({ "city": "Seattle", "units": null })
);
}
#[tokio::test]
async fn typed_invoke_missing_required_field_errors_like_a_tool_error() {
let tool = FunctionTool::typed(
"get_weather",
"Get the weather.",
|_args: WeatherArgs| async move { Ok(serde_json::Value::Null) },
);
let err = tool.invoke(serde_json::json!({})).await.unwrap_err();
assert!(matches!(err, Error::Tool(_)));
assert!(err.to_string().contains("get_weather"));
}
#[tokio::test]
async fn typed_invoke_wrong_field_type_errors_like_a_tool_error() {
let tool = FunctionTool::typed(
"get_weather",
"Get the weather.",
|_args: WeatherArgs| async move { Ok(serde_json::Value::Null) },
);
let err = tool
.invoke(serde_json::json!({ "city": 5 }))
.await
.unwrap_err();
assert!(matches!(err, Error::Tool(_)));
}
#[tokio::test]
async fn typed_invoke_error_shape_matches_a_new_style_closure_error() {
let untyped = FunctionTool::new("f", "d", empty_schema(), |_args| async move {
Err(Error::tool("boom"))
});
let untyped_err = untyped.invoke(serde_json::json!({})).await.unwrap_err();
let typed = FunctionTool::typed("f", "d", |_args: WeatherArgs| async move {
Ok(serde_json::Value::Null)
});
let typed_err = typed.invoke(serde_json::json!({})).await.unwrap_err();
assert!(matches!(untyped_err, Error::Tool(_)));
assert!(matches!(typed_err, Error::Tool(_)));
}
#[derive(serde::Serialize)]
struct WeatherResult {
city: String,
temp_c: i32,
}
#[tokio::test]
async fn typed_invoke_serializes_a_generic_serializable_return_type() {
let tool = FunctionTool::typed(
"get_weather",
"Get the weather.",
|args: WeatherArgs| async move {
Ok(WeatherResult {
city: args.city,
temp_c: 21,
})
},
);
let result = tool
.invoke(serde_json::json!({ "city": "Portland" }))
.await
.unwrap();
assert_eq!(
result,
serde_json::json!({ "city": "Portland", "temp_c": 21 })
);
}
#[tokio::test]
async fn max_invocations_blocks_calls_past_the_limit() {
let calls = Arc::new(AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let tool = FunctionTool::new("f", "d", empty_schema(), move |_args| {
let calls = Arc::clone(&calls_clone);
async move {
calls.fetch_add(1, Ordering::SeqCst);
Ok(serde_json::Value::Null)
}
})
.max_invocations(2);
tool.invoke(serde_json::json!({})).await.unwrap();
tool.invoke(serde_json::json!({})).await.unwrap();
let err = tool.invoke(serde_json::json!({})).await.unwrap_err();
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(tool.invocation_count(), 2);
assert!(matches!(err, Error::Tool(_)));
assert_eq!(
err.to_string(),
"tool error: Function 'f' has reached its maximum invocation limit, \
you can no longer use this tool."
);
}
#[tokio::test]
async fn max_invocations_holds_under_concurrent_calls() {
let gate = Arc::new(tokio::sync::Notify::new());
let calls = Arc::new(AtomicUsize::new(0));
let (gate_c, calls_c) = (Arc::clone(&gate), Arc::clone(&calls));
let tool = Arc::new(
FunctionTool::new("f", "d", empty_schema(), move |_args| {
let (gate, calls) = (Arc::clone(&gate_c), Arc::clone(&calls_c));
async move {
calls.fetch_add(1, Ordering::SeqCst);
gate.notified().await;
Ok(serde_json::Value::Null)
}
})
.max_invocations(1),
);
let (t1, t2) = (Arc::clone(&tool), Arc::clone(&tool));
let h1 = tokio::spawn(async move { t1.invoke(serde_json::json!({})).await });
let h2 = tokio::spawn(async move { t2.invoke(serde_json::json!({})).await });
tokio::task::yield_now().await;
tokio::task::yield_now().await;
gate.notify_waiters();
gate.notify_waiters();
let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
let successes = [&r1, &r2].iter().filter(|r| r.is_ok()).count();
assert_eq!(successes, 1, "exactly one call may claim the single slot");
assert_eq!(calls.load(Ordering::SeqCst), 1, "the closure ran once");
assert_eq!(tool.invocation_count(), 1);
let err = [r1, r2].into_iter().find_map(|r| r.err()).unwrap();
assert!(err.to_string().contains("maximum invocation limit"));
}
#[tokio::test]
async fn max_invocation_exceptions_blocks_calls_past_the_limit() {
let tool = FunctionTool::new("f", "d", empty_schema(), |_args| async move {
Err(Error::tool("boom"))
})
.max_invocation_exceptions(2);
let first = tool.invoke(serde_json::json!({})).await.unwrap_err();
let second = tool.invoke(serde_json::json!({})).await.unwrap_err();
assert_eq!(first.to_string(), "tool error: boom");
assert_eq!(second.to_string(), "tool error: boom");
assert_eq!(tool.invocation_exception_count(), 2);
let third = tool.invoke(serde_json::json!({})).await.unwrap_err();
assert_eq!(
third.to_string(),
"tool error: Function 'f' has reached its maximum exception limit, \
you tried to use this tool too many times and it kept failing."
);
assert_eq!(tool.invocation_exception_count(), 2);
}
#[tokio::test]
async fn invocation_counters_are_shared_across_clones() {
let tool = FunctionTool::new("f", "d", empty_schema(), |_args| async move {
Ok(serde_json::Value::Null)
})
.max_invocations(1);
let clone = tool.clone();
clone.invoke(serde_json::json!({})).await.unwrap();
let err = tool.invoke(serde_json::json!({})).await.unwrap_err();
assert!(matches!(err, Error::Tool(_)));
assert_eq!(tool.invocation_count(), 1);
}
#[test]
fn description_setter_sets_tool_definition_description() {
let tool = hosted_mcp("docs", "https://mcp.example.com", None).description("My MCP");
assert_eq!(tool.description, "My MCP");
}
#[test]
fn user_location_setter_sets_parameter_key() {
let loc = serde_json::json!({ "city": "Seattle", "country": "US" });
let tool = hosted_web_search().user_location(loc.clone());
assert_eq!(tool.parameters["user_location"], loc);
}
#[test]
fn max_uses_setter_sets_parameter_key() {
let tool = hosted_web_search().max_uses(5);
assert_eq!(tool.parameters["max_uses"], serde_json::json!(5));
}
#[test]
fn connection_id_setter_sets_parameter_key() {
let tool = hosted_web_search().connection_id("conn-1");
assert_eq!(
tool.parameters["connection_id"],
serde_json::json!("conn-1")
);
}
#[test]
fn custom_connection_setter_sets_both_parameter_keys() {
let tool = hosted_web_search().custom_connection("custom-conn", "my-instance");
assert_eq!(
tool.parameters["custom_connection_id"],
serde_json::json!("custom-conn")
);
assert_eq!(
tool.parameters["instance_name"],
serde_json::json!("my-instance")
);
}
#[test]
fn vector_store_ids_setter_sets_parameter_key() {
let tool = hosted_file_search(None).vector_store_ids(vec!["vs_1".into(), "vs_2".into()]);
assert_eq!(
tool.parameters["vector_store_ids"],
serde_json::json!(["vs_1", "vs_2"])
);
}
#[test]
fn max_results_setter_sets_parameter_key() {
let tool = hosted_file_search(None).max_results(7);
assert_eq!(tool.parameters["max_results"], serde_json::json!(7));
}
#[test]
fn file_ids_setter_sets_parameter_key() {
let tool = hosted_code_interpreter().file_ids(vec!["file-1".into()]);
assert_eq!(tool.parameters["file_ids"], serde_json::json!(["file-1"]));
}
#[test]
fn container_setter_sets_parameter_key() {
let container = serde_json::json!({ "type": "secure", "id": "c1" });
let tool = hosted_code_interpreter().container(container.clone());
assert_eq!(tool.parameters["container"], container);
}
#[test]
fn headers_setter_sets_parameter_key() {
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer x".to_string());
let tool = hosted_mcp("docs", "https://mcp.example.com", None).headers(headers);
assert_eq!(
tool.parameters["headers"],
serde_json::json!({ "authorization": "Bearer x" })
);
}
#[test]
fn mcp_approval_mode_always_sets_string_parameter() {
let tool = hosted_mcp("docs", "https://mcp.example.com", None)
.mcp_approval_mode(McpApprovalMode::Always);
assert_eq!(
tool.parameters["approval_mode"],
serde_json::json!("always_require")
);
}
#[test]
fn mcp_approval_mode_never_sets_string_parameter() {
let tool = hosted_mcp("docs", "https://mcp.example.com", None)
.mcp_approval_mode(McpApprovalMode::Never);
assert_eq!(
tool.parameters["approval_mode"],
serde_json::json!("never_require")
);
}
#[test]
fn mcp_approval_mode_per_tool_sets_object_parameter_with_both_sides() {
let tool = hosted_mcp("docs", "https://mcp.example.com", None).mcp_approval_mode(
McpApprovalMode::PerTool {
always: vec!["delete".to_string()],
never: vec!["read".to_string()],
},
);
assert_eq!(
tool.parameters["approval_mode"],
serde_json::json!({ "always": ["delete"], "never": ["read"] })
);
}
#[test]
fn mcp_approval_mode_per_tool_omits_the_empty_side() {
let never_only = hosted_mcp("docs", "https://mcp.example.com", None).mcp_approval_mode(
McpApprovalMode::PerTool {
always: vec![],
never: vec!["read".to_string()],
},
);
assert_eq!(
never_only.parameters["approval_mode"],
serde_json::json!({ "never": ["read"] })
);
let always_only = hosted_mcp("docs", "https://mcp.example.com", None).mcp_approval_mode(
McpApprovalMode::PerTool {
always: vec!["delete".to_string()],
never: vec![],
},
);
assert_eq!(
always_only.parameters["approval_mode"],
serde_json::json!({ "always": ["delete"] })
);
}
#[test]
fn setters_chain_together_on_a_single_hosted_mcp_tool() {
let mut headers = HashMap::new();
headers.insert("authorization".to_string(), "Bearer x".to_string());
let tool = hosted_mcp("docs", "https://mcp.example.com", None)
.description("Docs server")
.headers(headers.clone())
.mcp_approval_mode(McpApprovalMode::Always);
assert_eq!(tool.description, "Docs server");
assert_eq!(tool.parameters["headers"], serde_json::json!(headers));
assert_eq!(
tool.parameters["approval_mode"],
serde_json::json!("always_require")
);
}
}