Skip to main content

fastmcp_server/
transform.rs

1//! Tool transformations for dynamic schema modification.
2//!
3//! This module provides the ability to transform tools dynamically, allowing:
4//! - Renaming tools and their arguments
5//! - Modifying descriptions
6//! - Providing default values for arguments
7//! - Hiding arguments from the schema (while still providing values)
8//! - Wrapping tools with custom transformation functions
9//!
10//! # Example
11//!
12//! ```ignore
13//! use fastmcp_server::transform::{ArgTransform, TransformedTool};
14//!
15//! // Original tool with cryptic argument names
16//! let original_tool = my_search_tool();
17//!
18//! // Transform to be more LLM-friendly
19//! let transformed = TransformedTool::from_tool(original_tool)
20//!     .name("semantic_search")
21//!     .description("Search for documents using natural language")
22//!     .transform_arg("q", ArgTransform::new().name("query").description("Search query"))
23//!     .transform_arg("n", ArgTransform::new().name("limit").default(10))
24//!     .build();
25//! ```
26
27use std::collections::HashMap;
28
29use fastmcp_core::{McpContext, McpOutcome, McpResult, Outcome};
30use fastmcp_protocol::{Content, Tool};
31
32use crate::handler::{BoxFuture, BoxedToolHandler, ToolHandler};
33
34/// Sentinel value for unset optional fields.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct NotSet;
37
38/// Transformation rules for a single argument.
39///
40/// Use the builder methods to specify which aspects of the argument to transform.
41/// Any field left as `None` will inherit from the original argument.
42#[derive(Debug, Clone, Default)]
43pub struct ArgTransform {
44    /// New name for the argument.
45    pub name: Option<String>,
46    /// New description for the argument.
47    pub description: Option<String>,
48    /// Default value (as JSON) for the argument.
49    pub default: Option<serde_json::Value>,
50    /// Whether to hide this argument from the schema.
51    /// Hidden arguments must have a default value.
52    pub hide: bool,
53    /// Override the required status.
54    /// Only `Some(true)` is meaningful (to make optional → required).
55    pub required: Option<bool>,
56    /// New type annotation for the argument (as JSON Schema).
57    pub type_schema: Option<serde_json::Value>,
58}
59
60impl ArgTransform {
61    /// Creates a new empty argument transform.
62    #[must_use]
63    pub fn new() -> Self {
64        <Self as Default>::default()
65    }
66
67    /// Sets the new name for this argument.
68    #[must_use]
69    pub fn name(mut self, name: impl Into<String>) -> Self {
70        self.name = Some(name.into());
71        self
72    }
73
74    /// Sets the new description for this argument.
75    #[must_use]
76    pub fn description(mut self, desc: impl Into<String>) -> Self {
77        self.description = Some(desc.into());
78        self
79    }
80
81    /// Sets the default value for this argument.
82    #[must_use]
83    pub fn default(mut self, value: impl Into<serde_json::Value>) -> Self {
84        self.default = Some(value.into());
85        self
86    }
87
88    /// Sets a string default value.
89    #[must_use]
90    pub fn default_str(self, value: impl Into<String>) -> Self {
91        self.default(serde_json::Value::String(value.into()))
92    }
93
94    /// Sets an integer default value.
95    #[must_use]
96    pub fn default_int(self, value: i64) -> Self {
97        self.default(serde_json::Value::Number(value.into()))
98    }
99
100    /// Sets a boolean default value.
101    #[must_use]
102    pub fn default_bool(self, value: bool) -> Self {
103        self.default(serde_json::Value::Bool(value))
104    }
105
106    /// Hides this argument from the schema.
107    ///
108    /// Hidden arguments are not exposed to the LLM but must have a default
109    /// value that will be used when the tool is called.
110    #[must_use]
111    pub fn hide(mut self) -> Self {
112        self.hide = true;
113        self
114    }
115
116    /// Makes this argument required (even if it was optional).
117    #[must_use]
118    pub fn required(mut self) -> Self {
119        self.required = Some(true);
120        self
121    }
122
123    /// Sets the JSON Schema type for this argument.
124    #[must_use]
125    pub fn type_schema(mut self, schema: serde_json::Value) -> Self {
126        self.type_schema = Some(schema);
127        self
128    }
129
130    /// Creates a transform that drops (hides) this argument with a default value.
131    #[must_use]
132    pub fn drop_with_default(value: impl Into<serde_json::Value>) -> Self {
133        Self::new().default(value).hide()
134    }
135}
136
137/// A transformed tool that wraps another tool and applies transformations.
138///
139/// Transformations can include:
140/// - Renaming the tool
141/// - Modifying the description
142/// - Transforming arguments (rename, add defaults, hide, etc.)
143/// - Applying a custom transformation function
144pub struct TransformedTool {
145    /// The underlying tool being transformed.
146    parent: BoxedToolHandler,
147    /// Transformed tool definition.
148    definition: Tool,
149    /// Argument transformations (keyed by original argument name).
150    arg_transforms: HashMap<String, ArgTransform>,
151    /// Mapping from new arg names to original arg names.
152    name_mapping: HashMap<String, String>,
153}
154
155impl TransformedTool {
156    /// Creates a builder for transforming an existing tool.
157    pub fn from_tool<H: ToolHandler + 'static>(tool: H) -> TransformedToolBuilder {
158        TransformedToolBuilder::new(Box::new(tool))
159    }
160
161    /// Creates a builder from a boxed tool handler.
162    pub fn from_boxed(tool: BoxedToolHandler) -> TransformedToolBuilder {
163        TransformedToolBuilder::new(tool)
164    }
165
166    /// Returns the parent tool's definition.
167    #[must_use]
168    pub fn parent_definition(&self) -> Tool {
169        self.parent.definition()
170    }
171
172    /// Returns the argument transforms.
173    #[must_use]
174    pub fn arg_transforms(&self) -> &HashMap<String, ArgTransform> {
175        &self.arg_transforms
176    }
177
178    /// Transforms the incoming arguments (with new names) to the original format.
179    fn transform_arguments(&self, arguments: serde_json::Value) -> McpResult<serde_json::Value> {
180        let mut args = match arguments {
181            serde_json::Value::Object(map) => map,
182            serde_json::Value::Null => serde_json::Map::new(),
183            _ => {
184                return Err(fastmcp_core::McpError::invalid_params(
185                    "Arguments must be an object",
186                ));
187            }
188        };
189
190        let mut result = serde_json::Map::new();
191
192        // Apply transformations
193        for (original_name, transform) in &self.arg_transforms {
194            let new_name = transform.name.as_ref().unwrap_or(original_name);
195
196            // Check if we have a value for this argument (using new name)
197            if let Some(value) = args.remove(new_name) {
198                // Use the provided value with original name
199                result.insert(original_name.clone(), value);
200            } else if let Some(default) = &transform.default {
201                // Use the default value
202                result.insert(original_name.clone(), default.clone());
203            } else if transform.hide {
204                // Hidden argument without default - error
205                return Err(fastmcp_core::McpError::invalid_params(format!(
206                    "Hidden argument '{}' requires a default value",
207                    original_name
208                )));
209            }
210            // Otherwise, don't include (let the parent tool handle missing args)
211        }
212
213        // Pass through any remaining arguments that weren't transformed
214        for (key, value) in args {
215            // Check if this key maps back to an original name
216            if let Some(original) = self.name_mapping.get(&key) {
217                result.insert(original.clone(), value);
218            } else {
219                result.insert(key, value);
220            }
221        }
222
223        Ok(serde_json::Value::Object(result))
224    }
225}
226
227impl std::fmt::Debug for TransformedTool {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("TransformedTool")
230            .field("definition", &self.definition)
231            .field("arg_transforms", &self.arg_transforms)
232            .finish_non_exhaustive()
233    }
234}
235
236impl ToolHandler for TransformedTool {
237    fn definition(&self) -> Tool {
238        self.definition.clone()
239    }
240
241    fn call(&self, ctx: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
242        let transformed_args = self.transform_arguments(arguments)?;
243        self.parent.call(ctx, transformed_args)
244    }
245
246    fn call_async<'a>(
247        &'a self,
248        ctx: &'a McpContext,
249        arguments: serde_json::Value,
250    ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
251        Box::pin(async move {
252            let transformed_args = match self.transform_arguments(arguments) {
253                Ok(args) => args,
254                Err(e) => return Outcome::Err(e),
255            };
256            self.parent.call_async(ctx, transformed_args).await
257        })
258    }
259}
260
261/// Builder for creating transformed tools.
262pub struct TransformedToolBuilder {
263    parent: BoxedToolHandler,
264    name: Option<String>,
265    description: Option<String>,
266    arg_transforms: HashMap<String, ArgTransform>,
267}
268
269impl TransformedToolBuilder {
270    /// Creates a new builder for the given parent tool.
271    pub fn new(parent: BoxedToolHandler) -> Self {
272        Self {
273            parent,
274            name: None,
275            description: None,
276            arg_transforms: HashMap::new(),
277        }
278    }
279
280    /// Sets the new name for the transformed tool.
281    #[must_use]
282    pub fn name(mut self, name: impl Into<String>) -> Self {
283        self.name = Some(name.into());
284        self
285    }
286
287    /// Sets the new description for the transformed tool.
288    #[must_use]
289    pub fn description(mut self, desc: impl Into<String>) -> Self {
290        self.description = Some(desc.into());
291        self
292    }
293
294    /// Adds a transformation for the given argument.
295    ///
296    /// The `original_name` is the name of the argument in the parent tool.
297    #[must_use]
298    pub fn transform_arg(
299        mut self,
300        original_name: impl Into<String>,
301        transform: ArgTransform,
302    ) -> Self {
303        self.arg_transforms.insert(original_name.into(), transform);
304        self
305    }
306
307    /// Renames an argument.
308    #[must_use]
309    pub fn rename_arg(self, original_name: impl Into<String>, new_name: impl Into<String>) -> Self {
310        self.transform_arg(original_name, ArgTransform::new().name(new_name))
311    }
312
313    /// Hides an argument and provides a default value.
314    #[must_use]
315    pub fn hide_arg(
316        self,
317        original_name: impl Into<String>,
318        default: impl Into<serde_json::Value>,
319    ) -> Self {
320        self.transform_arg(original_name, ArgTransform::drop_with_default(default))
321    }
322
323    /// Builds the transformed tool.
324    #[must_use]
325    pub fn build(self) -> TransformedTool {
326        let parent_def = self.parent.definition();
327
328        // Build name mapping (new name -> original name)
329        let mut name_mapping = HashMap::new();
330        for (original, transform) in &self.arg_transforms {
331            if let Some(new_name) = &transform.name {
332                name_mapping.insert(new_name.clone(), original.clone());
333            }
334        }
335
336        // Transform the tool definition
337        let definition = self.build_definition(&parent_def);
338
339        TransformedTool {
340            parent: self.parent,
341            definition,
342            arg_transforms: self.arg_transforms,
343            name_mapping,
344        }
345    }
346
347    /// Builds the transformed tool definition.
348    fn build_definition(&self, parent: &Tool) -> Tool {
349        let name = self.name.clone().unwrap_or_else(|| parent.name.clone());
350        let description = self
351            .description
352            .clone()
353            .or_else(|| parent.description.clone());
354
355        // Transform the input schema
356        let input_schema = self.transform_schema(&parent.input_schema);
357
358        Tool {
359            name,
360            description,
361            input_schema,
362            output_schema: parent.output_schema.clone(),
363            icon: parent.icon.clone(),
364            version: parent.version.clone(),
365            tags: parent.tags.clone(),
366            annotations: parent.annotations.clone(),
367        }
368    }
369
370    /// Transforms the input schema based on argument transforms.
371    fn transform_schema(&self, original: &serde_json::Value) -> serde_json::Value {
372        let mut schema = original.clone();
373
374        let Some(obj) = schema.as_object_mut() else {
375            return schema;
376        };
377
378        // Ensure properties and required exist
379        // Note: Using String::from() with static str is optimized by the compiler
380        // but explicit owned strings are required for serde_json::Map keys
381        if !obj.contains_key("properties") {
382            obj.insert(String::from("properties"), serde_json::json!({}));
383        }
384        if !obj.contains_key("required") {
385            obj.insert(String::from("required"), serde_json::json!([]));
386        }
387
388        // Track changes to apply
389        // Pre-allocate based on transform count to avoid reallocations
390        let capacity = self.arg_transforms.len();
391        let mut props_to_remove: Vec<String> = Vec::with_capacity(capacity);
392        let mut props_to_add: Vec<(String, serde_json::Value)> = Vec::with_capacity(capacity);
393        let mut required_renames: Vec<(String, String)> = Vec::with_capacity(capacity);
394        let mut required_removes: Vec<String> = Vec::with_capacity(capacity);
395
396        // First pass: collect property transformations
397        {
398            let props = obj["properties"].as_object().unwrap();
399
400            for (original_name, transform) in &self.arg_transforms {
401                if transform.hide {
402                    props_to_remove.push(original_name.clone());
403                    required_removes.push(original_name.clone());
404                    continue;
405                }
406
407                if let Some(prop_schema) = props.get(original_name).cloned() {
408                    let new_name = transform.name.as_ref().unwrap_or(original_name);
409                    let mut new_schema = prop_schema;
410
411                    // Apply description override
412                    if let (Some(desc), Some(schema_obj)) =
413                        (&transform.description, new_schema.as_object_mut())
414                    {
415                        schema_obj.insert(String::from("description"), serde_json::json!(desc));
416                    }
417
418                    // Apply type override
419                    if let Some(type_schema) = &transform.type_schema {
420                        new_schema = type_schema.clone();
421                    }
422
423                    // Apply default override
424                    if let (Some(default), Some(schema_obj)) =
425                        (&transform.default, new_schema.as_object_mut())
426                    {
427                        schema_obj.insert(String::from("default"), default.clone());
428                    }
429
430                    if new_name != original_name {
431                        props_to_remove.push(original_name.clone());
432                        props_to_add.push((new_name.clone(), new_schema));
433                        required_renames.push((original_name.clone(), new_name.clone()));
434                    } else {
435                        // Update in place
436                        props_to_add.push((original_name.clone(), new_schema));
437                    }
438                }
439            }
440        }
441
442        // Apply property changes
443        if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
444            for name in &props_to_remove {
445                props.remove(name);
446            }
447            for (name, prop_schema) in props_to_add {
448                props.insert(name, prop_schema);
449            }
450        }
451
452        // Apply required array changes
453        if let Some(required) = obj.get_mut("required").and_then(|r| r.as_array_mut()) {
454            // Handle renames
455            for (old_name, new_name) in required_renames {
456                if let Some(idx) = required.iter().position(|v| v.as_str() == Some(&old_name)) {
457                    required[idx] = serde_json::json!(new_name);
458                }
459            }
460            // Handle removes - compare &str directly to avoid allocation
461            required.retain(|v| {
462                v.as_str()
463                    .is_none_or(|s| !required_removes.iter().any(|r| r == s))
464            });
465        }
466
467        schema
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use fastmcp_protocol::Content;
475
476    struct SearchToolFixture {
477        name: String,
478        description: Option<String>,
479        schema: serde_json::Value,
480    }
481
482    impl SearchToolFixture {
483        fn new(name: &str) -> Self {
484            Self {
485                name: name.to_string(),
486                description: Some("Search tool".to_string()),
487                schema: serde_json::json!({
488                    "type": "object",
489                    "properties": {
490                        "q": {
491                            "type": "string",
492                            "description": "Query"
493                        },
494                        "n": {
495                            "type": "integer",
496                            "description": "Limit"
497                        }
498                    },
499                    "required": ["q"]
500                }),
501            }
502        }
503    }
504
505    impl ToolHandler for SearchToolFixture {
506        fn definition(&self) -> Tool {
507            Tool {
508                name: self.name.clone(),
509                description: self.description.clone(),
510                input_schema: self.schema.clone(),
511                output_schema: None,
512                icon: None,
513                version: None,
514                tags: vec![],
515                annotations: None,
516            }
517        }
518
519        fn call(&self, _ctx: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
520            Ok(vec![Content::Text {
521                text: format!("Search called with: {}", arguments),
522            }])
523        }
524    }
525
526    #[test]
527    fn test_rename_tool() {
528        let tool = SearchToolFixture::new("search");
529        let transformed = TransformedTool::from_tool(tool)
530            .name("semantic_search")
531            .description("Search semantically")
532            .build();
533
534        let def = transformed.definition();
535        assert_eq!(def.name, "semantic_search");
536        assert_eq!(def.description, Some("Search semantically".to_string()));
537    }
538
539    #[test]
540    fn test_rename_arg() {
541        let tool = SearchToolFixture::new("search");
542        let transformed = TransformedTool::from_tool(tool)
543            .rename_arg("q", "query")
544            .build();
545
546        let def = transformed.definition();
547        let props = def.input_schema["properties"].as_object().unwrap();
548
549        // Original name should be gone
550        assert!(!props.contains_key("q"));
551        // New name should exist
552        assert!(props.contains_key("query"));
553    }
554
555    #[test]
556    fn test_hide_arg() {
557        let tool = SearchToolFixture::new("search");
558        let transformed = TransformedTool::from_tool(tool).hide_arg("n", 10).build();
559
560        let def = transformed.definition();
561        let props = def.input_schema["properties"].as_object().unwrap();
562
563        // Hidden arg should not be in schema
564        assert!(!props.contains_key("n"));
565        // But q should still be there
566        assert!(props.contains_key("q"));
567    }
568
569    #[test]
570    fn test_transform_arguments() {
571        let tool = SearchToolFixture::new("search");
572        let transformed = TransformedTool::from_tool(tool)
573            .rename_arg("q", "query")
574            .hide_arg("n", 10)
575            .build();
576
577        // Input uses new names
578        let input = serde_json::json!({
579            "query": "hello world"
580        });
581
582        // Transform should map back to original names and add defaults
583        let result = transformed.transform_arguments(input).unwrap();
584        let obj = result.as_object().unwrap();
585
586        assert_eq!(obj.get("q").unwrap(), "hello world");
587        assert_eq!(obj.get("n").unwrap(), 10);
588    }
589
590    #[test]
591    fn test_arg_transform_builder() {
592        let transform = ArgTransform::new()
593            .name("search_query")
594            .description("The search query string")
595            .default_str("*")
596            .required();
597
598        assert_eq!(transform.name, Some("search_query".to_string()));
599        assert_eq!(
600            transform.description,
601            Some("The search query string".to_string())
602        );
603        assert_eq!(transform.default, Some(serde_json::json!("*")));
604        assert_eq!(transform.required, Some(true));
605        assert!(!transform.hide);
606    }
607
608    // ── ArgTransform type helpers ─────────────────────────────────────
609
610    #[test]
611    fn arg_transform_default_int() {
612        let t = ArgTransform::new().default_int(42);
613        assert_eq!(t.default, Some(serde_json::json!(42)));
614    }
615
616    #[test]
617    fn arg_transform_default_bool() {
618        let t = ArgTransform::new().default_bool(true);
619        assert_eq!(t.default, Some(serde_json::json!(true)));
620    }
621
622    #[test]
623    fn arg_transform_type_schema() {
624        let schema = serde_json::json!({"type": "number", "minimum": 0});
625        let t = ArgTransform::new().type_schema(schema.clone());
626        assert_eq!(t.type_schema, Some(schema));
627    }
628
629    #[test]
630    fn arg_transform_drop_with_default() {
631        let t = ArgTransform::drop_with_default("auto");
632        assert!(t.hide);
633        assert_eq!(t.default, Some(serde_json::json!("auto")));
634    }
635
636    #[test]
637    fn arg_transform_hide_sets_flag() {
638        let t = ArgTransform::new().hide();
639        assert!(t.hide);
640    }
641
642    #[test]
643    fn arg_transform_debug() {
644        let t = ArgTransform::new().name("x");
645        let debug = format!("{:?}", t);
646        assert!(debug.contains("ArgTransform"));
647    }
648
649    #[test]
650    fn arg_transform_clone() {
651        let t = ArgTransform::new().name("x").default_int(5);
652        let c = t.clone();
653        assert_eq!(c.name, Some("x".to_string()));
654        assert_eq!(c.default, Some(serde_json::json!(5)));
655    }
656
657    // ── TransformedTool accessors ─────────────────────────────────────
658
659    #[test]
660    fn transformed_tool_parent_definition() {
661        let tool = SearchToolFixture::new("original");
662        let transformed = TransformedTool::from_tool(tool).name("renamed").build();
663        let parent_def = transformed.parent_definition();
664        assert_eq!(parent_def.name, "original");
665    }
666
667    #[test]
668    fn transformed_tool_arg_transforms_accessor() {
669        let tool = SearchToolFixture::new("search");
670        let transformed = TransformedTool::from_tool(tool)
671            .rename_arg("q", "query")
672            .build();
673        let transforms = transformed.arg_transforms();
674        assert!(transforms.contains_key("q"));
675    }
676
677    #[test]
678    fn transformed_tool_debug_format() {
679        let tool = SearchToolFixture::new("search");
680        let transformed = TransformedTool::from_tool(tool).name("dbg_tool").build();
681        let debug = format!("{:?}", transformed);
682        assert!(debug.contains("TransformedTool"));
683        assert!(debug.contains("dbg_tool"));
684    }
685
686    #[test]
687    fn transformed_tool_from_boxed() {
688        let tool = Box::new(SearchToolFixture::new("boxed")) as BoxedToolHandler;
689        let transformed = TransformedTool::from_boxed(tool).name("unboxed").build();
690        assert_eq!(transformed.definition().name, "unboxed");
691    }
692
693    // ── transform_arguments edge cases ───────────────────────────────
694
695    #[test]
696    fn transform_arguments_null_treated_as_empty() {
697        let tool = SearchToolFixture::new("search");
698        let transformed = TransformedTool::from_tool(tool).hide_arg("n", 10).build();
699
700        let result = transformed
701            .transform_arguments(serde_json::Value::Null)
702            .unwrap();
703        let obj = result.as_object().unwrap();
704        assert_eq!(obj.get("n").unwrap(), 10);
705    }
706
707    #[test]
708    fn transform_arguments_non_object_returns_error() {
709        let tool = SearchToolFixture::new("search");
710        let transformed = TransformedTool::from_tool(tool).build();
711
712        let result = transformed.transform_arguments(serde_json::json!("bad"));
713        assert!(result.is_err());
714        let err = result.unwrap_err();
715        assert!(err.message.contains("Arguments must be an object"));
716    }
717
718    #[test]
719    fn transform_arguments_passthrough_unknown_args() {
720        let tool = SearchToolFixture::new("search");
721        let transformed = TransformedTool::from_tool(tool)
722            .rename_arg("q", "query")
723            .build();
724
725        let input = serde_json::json!({
726            "query": "test",
727            "extra": "value"
728        });
729        let result = transformed.transform_arguments(input).unwrap();
730        let obj = result.as_object().unwrap();
731        assert_eq!(obj.get("q").unwrap(), "test");
732        assert_eq!(obj.get("extra").unwrap(), "value");
733    }
734
735    #[test]
736    fn transform_arguments_hidden_without_default_errors() {
737        let tool = SearchToolFixture::new("search");
738        let transformed = TransformedTool::from_tool(tool)
739            .transform_arg("q", ArgTransform::new().hide())
740            .build();
741
742        let result = transformed.transform_arguments(serde_json::json!({}));
743        assert!(result.is_err());
744        assert!(
745            result
746                .unwrap_err()
747                .message
748                .contains("Hidden argument 'q' requires a default value")
749        );
750    }
751
752    // ── ToolHandler impl ─────────────────────────────────────────────
753
754    #[test]
755    fn transformed_tool_call_delegates_with_mapped_args() {
756        let tool = SearchToolFixture::new("search");
757        let transformed = TransformedTool::from_tool(tool)
758            .rename_arg("q", "query")
759            .build();
760
761        let cx = asupersync::Cx::for_testing();
762        let ctx = McpContext::new(cx, 1);
763        let result = transformed
764            .call(&ctx, serde_json::json!({"query": "hello"}))
765            .unwrap();
766        assert_eq!(result.len(), 1);
767    }
768
769    #[test]
770    fn transformed_tool_call_with_invalid_args_returns_error() {
771        let tool = SearchToolFixture::new("search");
772        let transformed = TransformedTool::from_tool(tool).build();
773
774        let cx = asupersync::Cx::for_testing();
775        let ctx = McpContext::new(cx, 1);
776        let result = transformed.call(&ctx, serde_json::json!("string_not_object"));
777        assert!(result.is_err());
778    }
779
780    // ── Builder keeps parent properties ──────────────────────────────
781
782    #[test]
783    fn builder_no_name_keeps_parent_name() {
784        let tool = SearchToolFixture::new("original_name");
785        let transformed = TransformedTool::from_tool(tool).build();
786        assert_eq!(transformed.definition().name, "original_name");
787    }
788
789    #[test]
790    fn builder_no_description_keeps_parent_description() {
791        let tool = SearchToolFixture::new("s");
792        let transformed = TransformedTool::from_tool(tool).build();
793        assert_eq!(
794            transformed.definition().description,
795            Some("Search tool".to_string())
796        );
797    }
798
799    // ── Schema transform: description override ───────────────────────
800
801    #[test]
802    fn transform_schema_applies_description_override() {
803        let tool = SearchToolFixture::new("s");
804        let transformed = TransformedTool::from_tool(tool)
805            .transform_arg("q", ArgTransform::new().description("Full search query"))
806            .build();
807
808        let def = transformed.definition();
809        let q_schema = &def.input_schema["properties"]["q"];
810        assert_eq!(q_schema["description"], "Full search query");
811    }
812
813    // ── NotSet sentinel ──────────────────────────────────────────────
814
815    #[test]
816    fn not_set_debug() {
817        let n = NotSet;
818        let debug = format!("{:?}", n);
819        assert!(debug.contains("NotSet"));
820    }
821
822    #[test]
823    fn not_set_clone_copy() {
824        let n = NotSet;
825        let cloned = n.clone();
826        let copied = n; // Copy
827        let _ = (cloned, copied);
828    }
829
830    #[test]
831    fn not_set_default() {
832        let _ = NotSet;
833    }
834
835    // ── ArgTransform defaults ────────────────────────────────────────
836
837    #[test]
838    fn arg_transform_new_is_all_none() {
839        let t = ArgTransform::new();
840        assert!(t.name.is_none());
841        assert!(t.description.is_none());
842        assert!(t.default.is_none());
843        assert!(!t.hide);
844        assert!(t.required.is_none());
845        assert!(t.type_schema.is_none());
846    }
847
848    #[test]
849    fn arg_transform_default_trait() {
850        let t = <ArgTransform as Default>::default();
851        assert!(t.name.is_none());
852        assert!(!t.hide);
853    }
854
855    // ── Schema transform: type override ──────────────────────────────
856
857    #[test]
858    fn transform_schema_applies_type_override() {
859        let tool = SearchToolFixture::new("s");
860        let transformed = TransformedTool::from_tool(tool)
861            .transform_arg(
862                "q",
863                ArgTransform::new().type_schema(serde_json::json!({"type": "number"})),
864            )
865            .build();
866
867        let def = transformed.definition();
868        let q_schema = &def.input_schema["properties"]["q"];
869        assert_eq!(q_schema["type"], "number");
870    }
871
872    // ── Schema transform: default value ──────────────────────────────
873
874    #[test]
875    fn transform_schema_applies_default_value() {
876        let tool = SearchToolFixture::new("s");
877        let transformed = TransformedTool::from_tool(tool)
878            .transform_arg("n", ArgTransform::new().default_int(25))
879            .build();
880
881        let def = transformed.definition();
882        let n_schema = &def.input_schema["properties"]["n"];
883        assert_eq!(n_schema["default"], 25);
884    }
885
886    // ── Schema rename updates required array ─────────────────────────
887
888    #[test]
889    fn transform_schema_rename_updates_required() {
890        let tool = SearchToolFixture::new("s");
891        let transformed = TransformedTool::from_tool(tool)
892            .rename_arg("q", "query")
893            .build();
894
895        let def = transformed.definition();
896        let required = def.input_schema["required"].as_array().unwrap();
897        assert!(required.iter().any(|v| v == "query"));
898        assert!(!required.iter().any(|v| v == "q"));
899    }
900
901    // ── Schema hide removes from required ────────────────────────────
902
903    #[test]
904    fn transform_schema_hide_removes_from_required() {
905        // Make a tool where "q" is required, then hide it
906        let tool = SearchToolFixture::new("s");
907        let transformed = TransformedTool::from_tool(tool)
908            .hide_arg("q", "default-query")
909            .build();
910
911        let def = transformed.definition();
912        let required = def.input_schema["required"].as_array().unwrap();
913        assert!(!required.iter().any(|v| v == "q"));
914    }
915
916    // ── Combined transforms ──────────────────────────────────────────
917
918    #[test]
919    fn combined_rename_description_default() {
920        let tool = SearchToolFixture::new("search");
921        let transformed = TransformedTool::from_tool(tool)
922            .transform_arg(
923                "n",
924                ArgTransform::new()
925                    .name("limit")
926                    .description("Max results")
927                    .default_int(10),
928            )
929            .build();
930
931        let def = transformed.definition();
932        let props = def.input_schema["properties"].as_object().unwrap();
933        assert!(!props.contains_key("n"));
934        let limit = props.get("limit").unwrap();
935        assert_eq!(limit["description"], "Max results");
936        assert_eq!(limit["default"], 10);
937    }
938
939    // ── build_definition preserves parent metadata ───────────────────
940
941    #[test]
942    fn build_definition_preserves_parent_output_schema() {
943        struct ToolWithOutputSchema;
944        impl ToolHandler for ToolWithOutputSchema {
945            fn definition(&self) -> Tool {
946                Tool {
947                    name: "parent".to_string(),
948                    description: None,
949                    input_schema: serde_json::json!({"type": "object"}),
950                    output_schema: Some(serde_json::json!({"type": "string"})),
951                    icon: None,
952                    version: Some("2.0".to_string()),
953                    tags: vec!["tag1".to_string()],
954                    annotations: None,
955                }
956            }
957            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
958                Ok(vec![])
959            }
960        }
961
962        let transformed = TransformedTool::from_tool(ToolWithOutputSchema)
963            .name("child")
964            .build();
965        let def = transformed.definition();
966        assert_eq!(
967            def.output_schema,
968            Some(serde_json::json!({"type": "string"}))
969        );
970        assert_eq!(def.version, Some("2.0".to_string()));
971        assert_eq!(def.tags, vec!["tag1".to_string()]);
972    }
973
974    // ── transform_schema with non-object schema ──────────────────────
975
976    #[test]
977    fn transform_schema_non_object_returned_as_is() {
978        struct ArraySchemaTool;
979        impl ToolHandler for ArraySchemaTool {
980            fn definition(&self) -> Tool {
981                Tool {
982                    name: "arr".to_string(),
983                    description: None,
984                    input_schema: serde_json::json!("not an object"),
985                    output_schema: None,
986                    icon: None,
987                    version: None,
988                    tags: vec![],
989                    annotations: None,
990                }
991            }
992            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
993                Ok(vec![])
994            }
995        }
996
997        let transformed = TransformedTool::from_tool(ArraySchemaTool)
998            .rename_arg("x", "y")
999            .build();
1000        let def = transformed.definition();
1001        // Schema is returned as-is since it's not an object
1002        assert_eq!(def.input_schema, serde_json::json!("not an object"));
1003    }
1004
1005    // ── Schema without properties or required ────────────────────────
1006
1007    #[test]
1008    fn transform_schema_adds_properties_and_required_if_missing() {
1009        struct MinimalSchemaTool;
1010        impl ToolHandler for MinimalSchemaTool {
1011            fn definition(&self) -> Tool {
1012                Tool {
1013                    name: "min".to_string(),
1014                    description: None,
1015                    input_schema: serde_json::json!({"type": "object"}),
1016                    output_schema: None,
1017                    icon: None,
1018                    version: None,
1019                    tags: vec![],
1020                    annotations: None,
1021                }
1022            }
1023            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1024                Ok(vec![])
1025            }
1026        }
1027
1028        let transformed = TransformedTool::from_tool(MinimalSchemaTool).build();
1029        let def = transformed.definition();
1030        assert!(def.input_schema["properties"].is_object());
1031        assert!(def.input_schema["required"].is_array());
1032    }
1033
1034    // ── TransformedTool call with hidden defaults ─────────────────────
1035
1036    #[test]
1037    fn transformed_tool_call_injects_hidden_defaults() {
1038        let tool = SearchToolFixture::new("search");
1039        let transformed = TransformedTool::from_tool(tool)
1040            .rename_arg("q", "query")
1041            .hide_arg("n", 5)
1042            .build();
1043
1044        let cx = asupersync::Cx::for_testing();
1045        let ctx = McpContext::new(cx, 1);
1046        let result = transformed
1047            .call(&ctx, serde_json::json!({"query": "test"}))
1048            .unwrap();
1049        // The result should contain the search output with mapped args
1050        assert_eq!(result.len(), 1);
1051        if let Content::Text { text } = &result[0] {
1052            assert!(text.contains("\"n\":5"));
1053            assert!(text.contains("\"q\":\"test\""));
1054        } else {
1055            panic!("expected text content");
1056        }
1057    }
1058
1059    // ── transform_arg with no-op transform ───────────────────────────
1060
1061    #[test]
1062    fn transform_arg_with_noop_keeps_original() {
1063        let tool = SearchToolFixture::new("search");
1064        let transformed = TransformedTool::from_tool(tool)
1065            .transform_arg("q", ArgTransform::new())
1066            .build();
1067
1068        let def = transformed.definition();
1069        let props = def.input_schema["properties"].as_object().unwrap();
1070        // q should still exist unchanged
1071        assert!(props.contains_key("q"));
1072    }
1073
1074    // ── transform_arg for non-existent arg ───────────────────────────
1075
1076    #[test]
1077    fn transform_arg_for_nonexistent_arg_is_ignored() {
1078        let tool = SearchToolFixture::new("search");
1079        let transformed = TransformedTool::from_tool(tool)
1080            .rename_arg("nonexistent", "renamed")
1081            .build();
1082
1083        let def = transformed.definition();
1084        let props = def.input_schema["properties"].as_object().unwrap();
1085        // Original args should be untouched
1086        assert!(props.contains_key("q"));
1087        assert!(props.contains_key("n"));
1088        // Renamed nonexistent shouldn't appear
1089        assert!(!props.contains_key("renamed"));
1090    }
1091
1092    #[test]
1093    fn call_async_delegates_with_mapped_args() {
1094        use fastmcp_core::block_on;
1095
1096        let tool = SearchToolFixture::new("search");
1097        let transformed = TransformedTool::from_tool(tool)
1098            .rename_arg("q", "query")
1099            .hide_arg("n", 7)
1100            .build();
1101
1102        let cx = asupersync::Cx::for_testing();
1103        let ctx = McpContext::new(cx, 1);
1104        let result = block_on(transformed.call_async(&ctx, serde_json::json!({"query": "async"})));
1105        let content = result.unwrap();
1106        assert_eq!(content.len(), 1);
1107        if let Content::Text { text } = &content[0] {
1108            assert!(text.contains("\"q\":\"async\""));
1109            assert!(text.contains("\"n\":7"));
1110        } else {
1111            panic!("expected text content");
1112        }
1113    }
1114
1115    #[test]
1116    fn transform_arguments_no_value_no_default_not_hidden_skipped() {
1117        let tool = SearchToolFixture::new("search");
1118        let transformed = TransformedTool::from_tool(tool)
1119            .transform_arg("n", ArgTransform::new().description("ignored desc"))
1120            .build();
1121
1122        // Don't supply "n" at all - should skip it (no default, not hidden)
1123        let result = transformed
1124            .transform_arguments(serde_json::json!({"q": "hello"}))
1125            .unwrap();
1126        let obj = result.as_object().unwrap();
1127        assert_eq!(obj.get("q").unwrap(), "hello");
1128        assert!(
1129            obj.get("n").is_none(),
1130            "missing arg without default should be skipped"
1131        );
1132    }
1133
1134    #[test]
1135    fn transform_arguments_default_used_without_hide() {
1136        let tool = SearchToolFixture::new("search");
1137        let transformed = TransformedTool::from_tool(tool)
1138            .transform_arg("n", ArgTransform::new().default_int(99))
1139            .build();
1140
1141        // Don't supply "n" - default should kick in even though not hidden
1142        let result = transformed
1143            .transform_arguments(serde_json::json!({"q": "test"}))
1144            .unwrap();
1145        let obj = result.as_object().unwrap();
1146        assert_eq!(obj.get("n").unwrap(), 99);
1147    }
1148
1149    #[test]
1150    fn build_definition_parent_no_description_returns_none() {
1151        struct NoDescTool;
1152        impl ToolHandler for NoDescTool {
1153            fn definition(&self) -> Tool {
1154                Tool {
1155                    name: "nodesc".to_string(),
1156                    description: None,
1157                    input_schema: serde_json::json!({"type": "object"}),
1158                    output_schema: None,
1159                    icon: None,
1160                    version: None,
1161                    tags: vec![],
1162                    annotations: None,
1163                }
1164            }
1165            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1166                Ok(vec![])
1167            }
1168        }
1169
1170        let transformed = TransformedTool::from_tool(NoDescTool).build();
1171        assert!(transformed.definition().description.is_none());
1172    }
1173
1174    #[test]
1175    fn transform_schema_preserves_unrenamed_in_required() {
1176        // Create a tool with two required args; rename only one
1177        struct TwoReqTool;
1178        impl ToolHandler for TwoReqTool {
1179            fn definition(&self) -> Tool {
1180                Tool {
1181                    name: "two".to_string(),
1182                    description: None,
1183                    input_schema: serde_json::json!({
1184                        "type": "object",
1185                        "properties": {
1186                            "a": {"type": "string"},
1187                            "b": {"type": "string"}
1188                        },
1189                        "required": ["a", "b"]
1190                    }),
1191                    output_schema: None,
1192                    icon: None,
1193                    version: None,
1194                    tags: vec![],
1195                    annotations: None,
1196                }
1197            }
1198            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1199                Ok(vec![])
1200            }
1201        }
1202
1203        let transformed = TransformedTool::from_tool(TwoReqTool)
1204            .rename_arg("a", "alpha")
1205            .build();
1206        let def = transformed.definition();
1207        let required = def.input_schema["required"].as_array().unwrap();
1208        assert!(
1209            required.iter().any(|v| v == "alpha"),
1210            "renamed arg in required"
1211        );
1212        assert!(
1213            required.iter().any(|v| v == "b"),
1214            "unrenamed arg still in required"
1215        );
1216        assert!(
1217            !required.iter().any(|v| v == "a"),
1218            "old name removed from required"
1219        );
1220    }
1221
1222    #[test]
1223    fn type_schema_replaces_entire_property() {
1224        let tool = SearchToolFixture::new("s");
1225        let transformed = TransformedTool::from_tool(tool)
1226            .transform_arg(
1227                "q",
1228                ArgTransform::new()
1229                    .type_schema(serde_json::json!({"type": "array", "items": {"type": "string"}})),
1230            )
1231            .build();
1232
1233        let def = transformed.definition();
1234        let q_schema = &def.input_schema["properties"]["q"];
1235        // Should have the new type, not the old "string"
1236        assert_eq!(q_schema["type"], "array");
1237        assert!(q_schema["items"].is_object());
1238        // The old description should NOT be present (type_schema replaces entirely)
1239        assert!(q_schema.get("description").is_none());
1240    }
1241}