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;
28use std::time::Duration;
29
30use asupersync::Cx;
31use fastmcp_core::{McpContext, McpOutcome, McpResult, Outcome};
32use fastmcp_protocol::common_types::{OpenMetadata, RawIcon};
33use fastmcp_protocol::{
34    CompleteResult, Content, FinalCallToolResult, FinalTool, Icon, Tool, ToolAnnotations,
35};
36
37use crate::bidirectional::MrtrCompletedInputs;
38use crate::handler::{
39    BoxFuture, BoxedToolHandler, FinalToolOutcome, FinalToolSchemaAuthority, ToolErrorKind,
40    ToolHandler, UpstreamFinalToolSchemaRegistration,
41};
42
43/// Sentinel value for unset optional fields.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct NotSet;
46
47/// Transformation rules for a single argument.
48///
49/// Use the builder methods to specify which aspects of the argument to transform.
50/// Any field left as `None` will inherit from the original argument.
51#[derive(Debug, Clone, Default)]
52pub struct ArgTransform {
53    /// New name for the argument.
54    pub name: Option<String>,
55    /// New description for the argument.
56    pub description: Option<String>,
57    /// Default value (as JSON) for the argument.
58    pub default: Option<serde_json::Value>,
59    /// Whether to hide this argument from the schema.
60    /// Hidden arguments must have a default value.
61    pub hide: bool,
62    /// Override the required status.
63    /// Only `Some(true)` is meaningful (to make optional → required).
64    pub required: Option<bool>,
65    /// New type annotation for the argument (as JSON Schema).
66    pub type_schema: Option<serde_json::Value>,
67}
68
69impl ArgTransform {
70    /// Creates a new empty argument transform.
71    #[must_use]
72    pub fn new() -> Self {
73        <Self as Default>::default()
74    }
75
76    /// Sets the new name for this argument.
77    #[must_use]
78    pub fn name(mut self, name: impl Into<String>) -> Self {
79        self.name = Some(name.into());
80        self
81    }
82
83    /// Sets the new description for this argument.
84    #[must_use]
85    pub fn description(mut self, desc: impl Into<String>) -> Self {
86        self.description = Some(desc.into());
87        self
88    }
89
90    /// Sets the default value for this argument.
91    #[must_use]
92    pub fn default(mut self, value: impl Into<serde_json::Value>) -> Self {
93        self.default = Some(value.into());
94        self
95    }
96
97    /// Sets a string default value.
98    #[must_use]
99    pub fn default_str(self, value: impl Into<String>) -> Self {
100        self.default(serde_json::Value::String(value.into()))
101    }
102
103    /// Sets an integer default value.
104    #[must_use]
105    pub fn default_int(self, value: i64) -> Self {
106        self.default(serde_json::Value::Number(value.into()))
107    }
108
109    /// Sets a boolean default value.
110    #[must_use]
111    pub fn default_bool(self, value: bool) -> Self {
112        self.default(serde_json::Value::Bool(value))
113    }
114
115    /// Hides this argument from the schema.
116    ///
117    /// Hidden arguments are not exposed to the LLM but must have a default
118    /// value that will be used when the tool is called.
119    #[must_use]
120    pub fn hide(mut self) -> Self {
121        self.hide = true;
122        self
123    }
124
125    /// Makes this argument required (even if it was optional).
126    #[must_use]
127    pub fn required(mut self) -> Self {
128        self.required = Some(true);
129        self
130    }
131
132    /// Sets the JSON Schema type for this argument.
133    #[must_use]
134    pub fn type_schema(mut self, schema: serde_json::Value) -> Self {
135        self.type_schema = Some(schema);
136        self
137    }
138
139    /// Creates a transform that drops (hides) this argument with a default value.
140    #[must_use]
141    pub fn drop_with_default(value: impl Into<serde_json::Value>) -> Self {
142        Self::new().default(value).hide()
143    }
144}
145
146/// A transformed tool that wraps another tool and applies transformations.
147///
148/// Transformations can include:
149/// - Renaming the tool
150/// - Modifying the description
151/// - Transforming arguments (rename, add defaults, hide, etc.)
152/// - Applying a custom transformation function
153pub struct TransformedTool {
154    /// The underlying tool being transformed.
155    parent: BoxedToolHandler,
156    /// Transformed tool definition.
157    definition: Tool,
158    /// Argument transformations (keyed by original argument name).
159    arg_transforms: HashMap<String, ArgTransform>,
160    /// Mapping from new arg names to original arg names.
161    name_mapping: HashMap<String, String>,
162}
163
164impl TransformedTool {
165    /// Creates a builder for transforming an existing tool.
166    pub fn from_tool<H: ToolHandler + 'static>(tool: H) -> TransformedToolBuilder {
167        TransformedToolBuilder::new(Box::new(tool))
168    }
169
170    /// Creates a builder from a boxed tool handler.
171    pub fn from_boxed(tool: BoxedToolHandler) -> TransformedToolBuilder {
172        TransformedToolBuilder::new(tool)
173    }
174
175    /// Returns the parent tool's definition.
176    #[must_use]
177    pub fn parent_definition(&self) -> Tool {
178        self.parent.definition()
179    }
180
181    /// Returns the argument transforms.
182    #[must_use]
183    pub fn arg_transforms(&self) -> &HashMap<String, ArgTransform> {
184        &self.arg_transforms
185    }
186
187    /// Transforms the incoming arguments (with new names) to the original format.
188    fn transform_arguments(&self, arguments: serde_json::Value) -> McpResult<serde_json::Value> {
189        let mut args = match arguments {
190            serde_json::Value::Object(map) => map,
191            serde_json::Value::Null => serde_json::Map::new(),
192            _ => {
193                return Err(fastmcp_core::McpError::invalid_params(
194                    "Arguments must be an object",
195                ));
196            }
197        };
198
199        let mut result = serde_json::Map::new();
200
201        // Apply transformations
202        for (original_name, transform) in &self.arg_transforms {
203            let new_name = transform.name.as_ref().unwrap_or(original_name);
204
205            if transform.hide {
206                // Hidden arguments are server-owned. A caller-supplied value
207                // under the published name or the original name must not
208                // override the configured default, and cannot substitute for
209                // a missing one.
210                args.remove(new_name);
211                args.remove(original_name);
212                if let Some(default) = &transform.default {
213                    result.insert(original_name.clone(), default.clone());
214                    continue;
215                }
216                return Err(fastmcp_core::McpError::invalid_params(format!(
217                    "Hidden argument '{original_name}' requires a default value"
218                )));
219            }
220
221            if let Some(value) = args.remove(new_name) {
222                result.insert(original_name.clone(), value);
223            } else if let Some(default) = &transform.default {
224                result.insert(original_name.clone(), default.clone());
225            }
226            // A caller who still sends the original name after a rename must
227            // not overwrite the mapped value in the leftover-args pass.
228            if new_name != original_name {
229                args.remove(original_name);
230            }
231        }
232
233        // Pass through any remaining arguments that weren't transformed
234        for (key, value) in args {
235            // Check if this key maps back to an original name
236            if let Some(original) = self.name_mapping.get(&key) {
237                result.insert(original.clone(), value);
238            } else {
239                result.insert(key, value);
240            }
241        }
242
243        Ok(serde_json::Value::Object(result))
244    }
245}
246
247impl std::fmt::Debug for TransformedTool {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.debug_struct("TransformedTool")
250            .field("definition", &self.definition)
251            .field("arg_transforms", &self.arg_transforms)
252            .finish_non_exhaustive()
253    }
254}
255
256impl ToolHandler for TransformedTool {
257    fn definition(&self) -> Tool {
258        self.definition.clone()
259    }
260
261    fn icon(&self) -> Option<&Icon> {
262        self.parent.icon()
263    }
264
265    fn version(&self) -> Option<&str> {
266        self.parent.version()
267    }
268
269    fn tags(&self) -> &[String] {
270        self.parent.tags()
271    }
272
273    fn annotations(&self) -> Option<&ToolAnnotations> {
274        self.parent.annotations()
275    }
276
277    fn output_schema(&self) -> Option<serde_json::Value> {
278        self.parent.output_schema()
279    }
280
281    fn final_title(&self) -> Option<&str> {
282        self.parent.final_title()
283    }
284
285    fn final_icons(&self) -> Option<&[RawIcon]> {
286        self.parent.final_icons()
287    }
288
289    fn final_metadata(&self) -> Option<&OpenMetadata> {
290        self.parent.final_metadata()
291    }
292
293    fn final_definition(&self) -> Option<FinalTool> {
294        let mut definition = self.parent.final_definition()?;
295        definition.name.clone_from(&self.definition.name);
296        definition
297            .description
298            .clone_from(&self.definition.description);
299        definition.input_schema =
300            transform_input_schema(&self.arg_transforms, &definition.input_schema);
301        Some(definition)
302    }
303
304    fn final_tool_schema_authority(&self) -> FinalToolSchemaAuthority {
305        self.parent.final_tool_schema_authority()
306    }
307
308    fn upstream_final_tool_schema_registration(
309        &self,
310    ) -> Option<UpstreamFinalToolSchemaRegistration> {
311        self.parent.upstream_final_tool_schema_registration()
312    }
313
314    fn final_tool_error_structured_content(
315        &self,
316        kind: ToolErrorKind,
317    ) -> Option<serde_json::Value> {
318        self.parent.final_tool_error_structured_content(kind)
319    }
320
321    fn declares_final_tasks(&self) -> bool {
322        self.parent.declares_final_tasks()
323    }
324
325    fn declares_final_mrtr(&self) -> bool {
326        self.parent.declares_final_mrtr()
327    }
328
329    fn timeout(&self) -> Option<Duration> {
330        self.parent.timeout()
331    }
332
333    fn call(&self, ctx: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
334        let transformed_args = self.transform_arguments(arguments)?;
335        self.parent.call(ctx, transformed_args)
336    }
337
338    fn call_async<'a>(
339        &'a self,
340        ctx: &'a McpContext,
341        arguments: serde_json::Value,
342    ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
343        Box::pin(async move {
344            let transformed_args = match self.transform_arguments(arguments) {
345                Ok(args) => args,
346                Err(error) => return Outcome::Err(error),
347            };
348            self.parent.call_async(ctx, transformed_args).await
349        })
350    }
351
352    fn call_async_in_request<'a>(
353        &'a self,
354        ctx: &'a McpContext,
355        request_cx: &'a Cx,
356        arguments: serde_json::Value,
357    ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
358        Box::pin(async move {
359            let transformed_args = match self.transform_arguments(arguments) {
360                Ok(args) => args,
361                Err(error) => return Outcome::Err(error),
362            };
363            self.parent
364                .call_async_in_request(ctx, request_cx, transformed_args)
365                .await
366        })
367    }
368
369    fn call_final(
370        &self,
371        ctx: &McpContext,
372        arguments: serde_json::Value,
373    ) -> McpResult<CompleteResult<FinalCallToolResult>> {
374        self.parent
375            .call_final(ctx, self.transform_arguments(arguments)?)
376    }
377
378    fn call_final_async<'a>(
379        &'a self,
380        ctx: &'a McpContext,
381        arguments: serde_json::Value,
382    ) -> BoxFuture<'a, McpOutcome<CompleteResult<FinalCallToolResult>>> {
383        Box::pin(async move {
384            let transformed_args = match self.transform_arguments(arguments) {
385                Ok(args) => args,
386                Err(error) => return Outcome::Err(error),
387            };
388            self.parent.call_final_async(ctx, transformed_args).await
389        })
390    }
391
392    fn call_final_async_in_request<'a>(
393        &'a self,
394        ctx: &'a McpContext,
395        request_cx: &'a Cx,
396        arguments: serde_json::Value,
397    ) -> BoxFuture<'a, McpOutcome<CompleteResult<FinalCallToolResult>>> {
398        Box::pin(async move {
399            let transformed_args = match self.transform_arguments(arguments) {
400                Ok(args) => args,
401                Err(error) => return Outcome::Err(error),
402            };
403            self.parent
404                .call_final_async_in_request(ctx, request_cx, transformed_args)
405                .await
406        })
407    }
408
409    fn call_final_outcome(
410        &self,
411        ctx: &McpContext,
412        arguments: serde_json::Value,
413    ) -> McpResult<FinalToolOutcome> {
414        self.parent
415            .call_final_outcome(ctx, self.transform_arguments(arguments)?)
416    }
417
418    fn call_final_outcome_async<'a>(
419        &'a self,
420        ctx: &'a McpContext,
421        arguments: serde_json::Value,
422    ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
423        Box::pin(async move {
424            let transformed_args = match self.transform_arguments(arguments) {
425                Ok(args) => args,
426                Err(error) => return Outcome::Err(error),
427            };
428            self.parent
429                .call_final_outcome_async(ctx, transformed_args)
430                .await
431        })
432    }
433
434    fn call_final_outcome_async_in_request<'a>(
435        &'a self,
436        ctx: &'a McpContext,
437        request_cx: &'a Cx,
438        arguments: serde_json::Value,
439    ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
440        Box::pin(async move {
441            let transformed_args = match self.transform_arguments(arguments) {
442                Ok(args) => args,
443                Err(error) => return Outcome::Err(error),
444            };
445            self.parent
446                .call_final_outcome_async_in_request(ctx, request_cx, transformed_args)
447                .await
448        })
449    }
450
451    fn call_final_outcome_async_resuming_in_request<'a>(
452        &'a self,
453        ctx: &'a McpContext,
454        request_cx: &'a Cx,
455        arguments: serde_json::Value,
456        resume_inputs: Option<&'a MrtrCompletedInputs>,
457    ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
458        Box::pin(async move {
459            let transformed_args = match self.transform_arguments(arguments) {
460                Ok(args) => args,
461                Err(error) => return Outcome::Err(error),
462            };
463            self.parent
464                .call_final_outcome_async_resuming_in_request(
465                    ctx,
466                    request_cx,
467                    transformed_args,
468                    resume_inputs,
469                )
470                .await
471        })
472    }
473}
474
475/// Builder for creating transformed tools.
476pub struct TransformedToolBuilder {
477    parent: BoxedToolHandler,
478    name: Option<String>,
479    description: Option<String>,
480    arg_transforms: HashMap<String, ArgTransform>,
481}
482
483impl TransformedToolBuilder {
484    /// Creates a new builder for the given parent tool.
485    pub fn new(parent: BoxedToolHandler) -> Self {
486        Self {
487            parent,
488            name: None,
489            description: None,
490            arg_transforms: HashMap::new(),
491        }
492    }
493
494    /// Sets the new name for the transformed tool.
495    #[must_use]
496    pub fn name(mut self, name: impl Into<String>) -> Self {
497        self.name = Some(name.into());
498        self
499    }
500
501    /// Sets the new description for the transformed tool.
502    #[must_use]
503    pub fn description(mut self, desc: impl Into<String>) -> Self {
504        self.description = Some(desc.into());
505        self
506    }
507
508    /// Adds a transformation for the given argument.
509    ///
510    /// The `original_name` is the name of the argument in the parent tool.
511    #[must_use]
512    pub fn transform_arg(
513        mut self,
514        original_name: impl Into<String>,
515        transform: ArgTransform,
516    ) -> Self {
517        self.arg_transforms.insert(original_name.into(), transform);
518        self
519    }
520
521    /// Renames an argument.
522    #[must_use]
523    pub fn rename_arg(self, original_name: impl Into<String>, new_name: impl Into<String>) -> Self {
524        self.transform_arg(original_name, ArgTransform::new().name(new_name))
525    }
526
527    /// Hides an argument and provides a default value.
528    #[must_use]
529    pub fn hide_arg(
530        self,
531        original_name: impl Into<String>,
532        default: impl Into<serde_json::Value>,
533    ) -> Self {
534        self.transform_arg(original_name, ArgTransform::drop_with_default(default))
535    }
536
537    /// Builds the transformed tool.
538    #[must_use]
539    pub fn build(self) -> TransformedTool {
540        let parent_def = self.parent.definition();
541
542        // Build name mapping (new name -> original name)
543        let mut name_mapping = HashMap::new();
544        for (original, transform) in &self.arg_transforms {
545            if let Some(new_name) = &transform.name {
546                name_mapping.insert(new_name.clone(), original.clone());
547            }
548        }
549
550        // Transform the tool definition
551        let definition = self.build_definition(&parent_def);
552
553        TransformedTool {
554            parent: self.parent,
555            definition,
556            arg_transforms: self.arg_transforms,
557            name_mapping,
558        }
559    }
560
561    /// Builds the transformed tool definition.
562    fn build_definition(&self, parent: &Tool) -> Tool {
563        let name = self.name.clone().unwrap_or_else(|| parent.name.clone());
564        let description = self
565            .description
566            .clone()
567            .or_else(|| parent.description.clone());
568
569        // Transform the input schema
570        let input_schema = transform_input_schema(&self.arg_transforms, &parent.input_schema);
571
572        Tool {
573            name,
574            description,
575            input_schema,
576            output_schema: parent.output_schema.clone(),
577            icon: parent.icon.clone(),
578            version: parent.version.clone(),
579            tags: parent.tags.clone(),
580            annotations: parent.annotations.clone(),
581        }
582    }
583}
584
585/// Rewrites a JSON Schema object according to argument rename/hide/default rules.
586fn transform_input_schema(
587    arg_transforms: &HashMap<String, ArgTransform>,
588    original: &serde_json::Value,
589) -> serde_json::Value {
590    let mut schema = original.clone();
591
592    let Some(obj) = schema.as_object_mut() else {
593        return schema;
594    };
595
596    // Ensure properties and required exist as the shapes later mutation
597    // expects. A parent tool may publish a non-object `properties` or
598    // non-array `required`; replacing those malformed members keeps
599    // `build()` from panicking on a definition the caller already owns.
600    if !obj
601        .get("properties")
602        .is_some_and(serde_json::Value::is_object)
603    {
604        obj.insert(String::from("properties"), serde_json::json!({}));
605    }
606    if !obj.get("required").is_some_and(serde_json::Value::is_array) {
607        obj.insert(String::from("required"), serde_json::json!([]));
608    }
609
610    // Track changes to apply
611    // Pre-allocate based on transform count to avoid reallocations
612    let capacity = arg_transforms.len();
613    let mut props_to_remove: Vec<String> = Vec::with_capacity(capacity);
614    let mut props_to_add: Vec<(String, serde_json::Value)> = Vec::with_capacity(capacity);
615    let mut required_renames: Vec<(String, String)> = Vec::with_capacity(capacity);
616    let mut required_removes: Vec<String> = Vec::with_capacity(capacity);
617
618    // First pass: collect property transformations
619    {
620        let Some(props) = obj.get("properties").and_then(serde_json::Value::as_object) else {
621            return schema;
622        };
623
624        for (original_name, transform) in arg_transforms {
625            if transform.hide {
626                props_to_remove.push(original_name.clone());
627                required_removes.push(original_name.clone());
628                continue;
629            }
630
631            if let Some(prop_schema) = props.get(original_name).cloned() {
632                let new_name = transform.name.as_ref().unwrap_or(original_name);
633                let mut new_schema = prop_schema;
634
635                // Apply description override
636                if let (Some(desc), Some(schema_obj)) =
637                    (&transform.description, new_schema.as_object_mut())
638                {
639                    schema_obj.insert(String::from("description"), serde_json::json!(desc));
640                }
641
642                // Apply type override
643                if let Some(type_schema) = &transform.type_schema {
644                    new_schema = type_schema.clone();
645                }
646
647                // Apply default override
648                if let (Some(default), Some(schema_obj)) =
649                    (&transform.default, new_schema.as_object_mut())
650                {
651                    schema_obj.insert(String::from("default"), default.clone());
652                }
653
654                if new_name != original_name {
655                    props_to_remove.push(original_name.clone());
656                    props_to_add.push((new_name.clone(), new_schema));
657                    required_renames.push((original_name.clone(), new_name.clone()));
658                } else {
659                    // Update in place
660                    props_to_add.push((original_name.clone(), new_schema));
661                }
662            }
663        }
664    }
665
666    // Apply property changes
667    if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
668        for name in &props_to_remove {
669            props.remove(name);
670        }
671        for (name, prop_schema) in props_to_add {
672            props.insert(name, prop_schema);
673        }
674    }
675
676    // Apply required array changes
677    if let Some(required) = obj.get_mut("required").and_then(|r| r.as_array_mut()) {
678        // Handle renames
679        for (old_name, new_name) in required_renames {
680            if let Some(idx) = required.iter().position(|v| v.as_str() == Some(&old_name)) {
681                required[idx] = serde_json::json!(new_name);
682            }
683        }
684        // Handle removes - compare &str directly to avoid allocation
685        required.retain(|v| {
686            v.as_str()
687                .is_none_or(|s| !required_removes.iter().any(|r| r == s))
688        });
689    }
690
691    schema
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use fastmcp_core::block_on;
698    use fastmcp_protocol::Content;
699    use fastmcp_protocol::common_types::ContentBlock;
700
701    struct SearchToolFixture {
702        name: String,
703        description: Option<String>,
704        schema: serde_json::Value,
705    }
706
707    impl SearchToolFixture {
708        fn new(name: &str) -> Self {
709            Self {
710                name: name.to_string(),
711                description: Some("Search tool".to_string()),
712                schema: serde_json::json!({
713                    "type": "object",
714                    "properties": {
715                        "q": {
716                            "type": "string",
717                            "description": "Query"
718                        },
719                        "n": {
720                            "type": "integer",
721                            "description": "Limit"
722                        }
723                    },
724                    "required": ["q"]
725                }),
726            }
727        }
728    }
729
730    impl ToolHandler for SearchToolFixture {
731        fn definition(&self) -> Tool {
732            Tool {
733                name: self.name.clone(),
734                description: self.description.clone(),
735                input_schema: self.schema.clone(),
736                output_schema: None,
737                icon: None,
738                version: None,
739                tags: vec![],
740                annotations: None,
741            }
742        }
743
744        fn call(&self, _ctx: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
745            Ok(vec![Content::Text {
746                text: format!("Search called with: {}", arguments),
747            }])
748        }
749    }
750
751    #[test]
752    fn test_rename_tool() {
753        let tool = SearchToolFixture::new("search");
754        let transformed = TransformedTool::from_tool(tool)
755            .name("semantic_search")
756            .description("Search semantically")
757            .build();
758
759        let def = transformed.definition();
760        assert_eq!(def.name, "semantic_search");
761        assert_eq!(def.description, Some("Search semantically".to_string()));
762    }
763
764    #[test]
765    fn test_rename_arg() {
766        let tool = SearchToolFixture::new("search");
767        let transformed = TransformedTool::from_tool(tool)
768            .rename_arg("q", "query")
769            .build();
770
771        let def = transformed.definition();
772        let props = def.input_schema["properties"].as_object().unwrap();
773
774        // Original name should be gone
775        assert!(!props.contains_key("q"));
776        // New name should exist
777        assert!(props.contains_key("query"));
778    }
779
780    #[test]
781    fn test_hide_arg() {
782        let tool = SearchToolFixture::new("search");
783        let transformed = TransformedTool::from_tool(tool).hide_arg("n", 10).build();
784
785        let def = transformed.definition();
786        let props = def.input_schema["properties"].as_object().unwrap();
787
788        // Hidden arg should not be in schema
789        assert!(!props.contains_key("n"));
790        // But q should still be there
791        assert!(props.contains_key("q"));
792    }
793
794    #[test]
795    fn test_transform_arguments() {
796        let tool = SearchToolFixture::new("search");
797        let transformed = TransformedTool::from_tool(tool)
798            .rename_arg("q", "query")
799            .hide_arg("n", 10)
800            .build();
801
802        // Input uses new names
803        let input = serde_json::json!({
804            "query": "hello world"
805        });
806
807        // Transform should map back to original names and add defaults
808        let result = transformed.transform_arguments(input).unwrap();
809        let obj = result.as_object().unwrap();
810
811        assert_eq!(obj.get("q").unwrap(), "hello world");
812        assert_eq!(obj.get("n").unwrap(), 10);
813    }
814
815    #[test]
816    fn test_arg_transform_builder() {
817        let transform = ArgTransform::new()
818            .name("search_query")
819            .description("The search query string")
820            .default_str("*")
821            .required();
822
823        assert_eq!(transform.name, Some("search_query".to_string()));
824        assert_eq!(
825            transform.description,
826            Some("The search query string".to_string())
827        );
828        assert_eq!(transform.default, Some(serde_json::json!("*")));
829        assert_eq!(transform.required, Some(true));
830        assert!(!transform.hide);
831    }
832
833    // ── ArgTransform type helpers ─────────────────────────────────────
834
835    #[test]
836    fn arg_transform_default_int() {
837        let t = ArgTransform::new().default_int(42);
838        assert_eq!(t.default, Some(serde_json::json!(42)));
839    }
840
841    #[test]
842    fn arg_transform_default_bool() {
843        let t = ArgTransform::new().default_bool(true);
844        assert_eq!(t.default, Some(serde_json::json!(true)));
845    }
846
847    #[test]
848    fn arg_transform_type_schema() {
849        let schema = serde_json::json!({"type": "number", "minimum": 0});
850        let t = ArgTransform::new().type_schema(schema.clone());
851        assert_eq!(t.type_schema, Some(schema));
852    }
853
854    #[test]
855    fn arg_transform_drop_with_default() {
856        let t = ArgTransform::drop_with_default("auto");
857        assert!(t.hide);
858        assert_eq!(t.default, Some(serde_json::json!("auto")));
859    }
860
861    #[test]
862    fn arg_transform_hide_sets_flag() {
863        let t = ArgTransform::new().hide();
864        assert!(t.hide);
865    }
866
867    #[test]
868    fn arg_transform_debug() {
869        let t = ArgTransform::new().name("x");
870        let debug = format!("{:?}", t);
871        assert!(debug.contains("ArgTransform"));
872    }
873
874    #[test]
875    fn arg_transform_clone() {
876        let t = ArgTransform::new().name("x").default_int(5);
877        let c = t.clone();
878        assert_eq!(c.name, Some("x".to_string()));
879        assert_eq!(c.default, Some(serde_json::json!(5)));
880    }
881
882    // ── TransformedTool accessors ─────────────────────────────────────
883
884    #[test]
885    fn transformed_tool_parent_definition() {
886        let tool = SearchToolFixture::new("original");
887        let transformed = TransformedTool::from_tool(tool).name("renamed").build();
888        let parent_def = transformed.parent_definition();
889        assert_eq!(parent_def.name, "original");
890    }
891
892    #[test]
893    fn transformed_tool_arg_transforms_accessor() {
894        let tool = SearchToolFixture::new("search");
895        let transformed = TransformedTool::from_tool(tool)
896            .rename_arg("q", "query")
897            .build();
898        let transforms = transformed.arg_transforms();
899        assert!(transforms.contains_key("q"));
900    }
901
902    #[test]
903    fn transformed_tool_debug_format() {
904        let tool = SearchToolFixture::new("search");
905        let transformed = TransformedTool::from_tool(tool).name("dbg_tool").build();
906        let debug = format!("{:?}", transformed);
907        assert!(debug.contains("TransformedTool"));
908        assert!(debug.contains("dbg_tool"));
909    }
910
911    #[test]
912    fn transformed_tool_from_boxed() {
913        let tool = Box::new(SearchToolFixture::new("boxed")) as BoxedToolHandler;
914        let transformed = TransformedTool::from_boxed(tool).name("unboxed").build();
915        assert_eq!(transformed.definition().name, "unboxed");
916    }
917
918    // ── transform_arguments edge cases ───────────────────────────────
919
920    #[test]
921    fn transform_arguments_null_treated_as_empty() {
922        let tool = SearchToolFixture::new("search");
923        let transformed = TransformedTool::from_tool(tool).hide_arg("n", 10).build();
924
925        let result = transformed
926            .transform_arguments(serde_json::Value::Null)
927            .unwrap();
928        let obj = result.as_object().unwrap();
929        assert_eq!(obj.get("n").unwrap(), 10);
930    }
931
932    #[test]
933    fn transform_arguments_non_object_returns_error() {
934        let tool = SearchToolFixture::new("search");
935        let transformed = TransformedTool::from_tool(tool).build();
936
937        let result = transformed.transform_arguments(serde_json::json!("bad"));
938        assert!(result.is_err());
939        let err = result.unwrap_err();
940        assert!(err.message.contains("Arguments must be an object"));
941    }
942
943    #[test]
944    fn transform_arguments_passthrough_unknown_args() {
945        let tool = SearchToolFixture::new("search");
946        let transformed = TransformedTool::from_tool(tool)
947            .rename_arg("q", "query")
948            .build();
949
950        let input = serde_json::json!({
951            "query": "test",
952            "extra": "value"
953        });
954        let result = transformed.transform_arguments(input).unwrap();
955        let obj = result.as_object().unwrap();
956        assert_eq!(obj.get("q").unwrap(), "test");
957        assert_eq!(obj.get("extra").unwrap(), "value");
958    }
959
960    #[test]
961    fn transform_arguments_rename_ignores_original_name_leftover() {
962        let tool = SearchToolFixture::new("search");
963        let transformed = TransformedTool::from_tool(tool)
964            .rename_arg("q", "query")
965            .build();
966
967        let result = transformed
968            .transform_arguments(serde_json::json!({
969                "query": "mapped",
970                "q": "leftover"
971            }))
972            .unwrap();
973        let obj = result.as_object().unwrap();
974        assert_eq!(obj.get("q").unwrap(), "mapped");
975        assert!(
976            obj.len() == 1,
977            "the unpublished original name must not leak beside the mapped value: {obj:?}"
978        );
979    }
980
981    #[test]
982    fn transform_arguments_hidden_without_default_errors() {
983        let tool = SearchToolFixture::new("search");
984        let transformed = TransformedTool::from_tool(tool)
985            .transform_arg("q", ArgTransform::new().hide())
986            .build();
987
988        let result = transformed.transform_arguments(serde_json::json!({}));
989        assert!(result.is_err());
990        assert!(
991            result
992                .unwrap_err()
993                .message
994                .contains("Hidden argument 'q' requires a default value")
995        );
996    }
997
998    #[test]
999    fn transform_arguments_hidden_default_ignores_caller_supplied_value() {
1000        let tool = SearchToolFixture::new("search");
1001        let transformed = TransformedTool::from_tool(tool).hide_arg("n", 10).build();
1002
1003        let result = transformed
1004            .transform_arguments(serde_json::json!({"q": "hello", "n": 999}))
1005            .unwrap();
1006        let obj = result.as_object().unwrap();
1007        assert_eq!(obj.get("q").unwrap(), "hello");
1008        assert_eq!(
1009            obj.get("n").unwrap(),
1010            10,
1011            "a hidden argument must keep its server default"
1012        );
1013    }
1014
1015    #[test]
1016    fn transform_arguments_hidden_renamed_strips_both_published_and_original_names() {
1017        let tool = SearchToolFixture::new("search");
1018        let transformed = TransformedTool::from_tool(tool)
1019            .transform_arg(
1020                "n",
1021                ArgTransform::new().name("limit").default_int(10).hide(),
1022            )
1023            .build();
1024
1025        let result = transformed
1026            .transform_arguments(serde_json::json!({
1027                "q": "hello",
1028                "n": 1,
1029                "limit": 2
1030            }))
1031            .unwrap();
1032        let obj = result.as_object().unwrap();
1033        assert_eq!(obj.get("q").unwrap(), "hello");
1034        assert_eq!(obj.get("n").unwrap(), 10);
1035        assert!(
1036            obj.get("limit").is_none(),
1037            "the unpublished hidden name must not leak into parent arguments"
1038        );
1039    }
1040
1041    #[test]
1042    fn transform_arguments_hidden_without_default_rejects_caller_value() {
1043        let tool = SearchToolFixture::new("search");
1044        let transformed = TransformedTool::from_tool(tool)
1045            .transform_arg("q", ArgTransform::new().hide())
1046            .build();
1047
1048        let result = transformed.transform_arguments(serde_json::json!({"q": "injected"}));
1049        assert!(result.is_err());
1050        assert!(
1051            result
1052                .unwrap_err()
1053                .message
1054                .contains("Hidden argument 'q' requires a default value")
1055        );
1056    }
1057
1058    // ── ToolHandler impl ─────────────────────────────────────────────
1059
1060    #[test]
1061    fn transformed_tool_call_delegates_with_mapped_args() {
1062        let tool = SearchToolFixture::new("search");
1063        let transformed = TransformedTool::from_tool(tool)
1064            .rename_arg("q", "query")
1065            .build();
1066
1067        let cx = asupersync::Cx::for_testing();
1068        let ctx = McpContext::new(cx, 1);
1069        let result = transformed
1070            .call(&ctx, serde_json::json!({"query": "hello"}))
1071            .unwrap();
1072        assert_eq!(result.len(), 1);
1073    }
1074
1075    #[test]
1076    fn transformed_tool_call_with_invalid_args_returns_error() {
1077        let tool = SearchToolFixture::new("search");
1078        let transformed = TransformedTool::from_tool(tool).build();
1079
1080        let cx = asupersync::Cx::for_testing();
1081        let ctx = McpContext::new(cx, 1);
1082        let result = transformed.call(&ctx, serde_json::json!("string_not_object"));
1083        assert!(result.is_err());
1084    }
1085
1086    // ── Builder keeps parent properties ──────────────────────────────
1087
1088    #[test]
1089    fn builder_no_name_keeps_parent_name() {
1090        let tool = SearchToolFixture::new("original_name");
1091        let transformed = TransformedTool::from_tool(tool).build();
1092        assert_eq!(transformed.definition().name, "original_name");
1093    }
1094
1095    #[test]
1096    fn builder_no_description_keeps_parent_description() {
1097        let tool = SearchToolFixture::new("s");
1098        let transformed = TransformedTool::from_tool(tool).build();
1099        assert_eq!(
1100            transformed.definition().description,
1101            Some("Search tool".to_string())
1102        );
1103    }
1104
1105    // ── Schema transform: description override ───────────────────────
1106
1107    #[test]
1108    fn transform_schema_applies_description_override() {
1109        let tool = SearchToolFixture::new("s");
1110        let transformed = TransformedTool::from_tool(tool)
1111            .transform_arg("q", ArgTransform::new().description("Full search query"))
1112            .build();
1113
1114        let def = transformed.definition();
1115        let q_schema = &def.input_schema["properties"]["q"];
1116        assert_eq!(q_schema["description"], "Full search query");
1117    }
1118
1119    // ── NotSet sentinel ──────────────────────────────────────────────
1120
1121    #[test]
1122    fn not_set_debug() {
1123        let n = NotSet;
1124        let debug = format!("{:?}", n);
1125        assert!(debug.contains("NotSet"));
1126    }
1127
1128    #[test]
1129    fn not_set_clone_copy() {
1130        let n = NotSet;
1131        let cloned = n.clone();
1132        let copied = n; // Copy
1133        let _ = (cloned, copied);
1134    }
1135
1136    #[test]
1137    fn not_set_default() {
1138        let _ = NotSet;
1139    }
1140
1141    // ── ArgTransform defaults ────────────────────────────────────────
1142
1143    #[test]
1144    fn arg_transform_new_is_all_none() {
1145        let t = ArgTransform::new();
1146        assert!(t.name.is_none());
1147        assert!(t.description.is_none());
1148        assert!(t.default.is_none());
1149        assert!(!t.hide);
1150        assert!(t.required.is_none());
1151        assert!(t.type_schema.is_none());
1152    }
1153
1154    #[test]
1155    fn arg_transform_default_trait() {
1156        let t = <ArgTransform as Default>::default();
1157        assert!(t.name.is_none());
1158        assert!(!t.hide);
1159    }
1160
1161    // ── Schema transform: type override ──────────────────────────────
1162
1163    #[test]
1164    fn transform_schema_replaces_malformed_properties_without_panicking() {
1165        struct MalformedSchemaTool;
1166        impl ToolHandler for MalformedSchemaTool {
1167            fn definition(&self) -> Tool {
1168                Tool {
1169                    name: "malformed".to_string(),
1170                    description: None,
1171                    input_schema: serde_json::json!({
1172                        "type": "object",
1173                        "properties": true,
1174                        "required": "q"
1175                    }),
1176                    output_schema: None,
1177                    icon: None,
1178                    version: None,
1179                    tags: vec![],
1180                    annotations: None,
1181                }
1182            }
1183            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1184                Ok(vec![])
1185            }
1186        }
1187
1188        let transformed = TransformedTool::from_tool(MalformedSchemaTool)
1189            .hide_arg("secret", "server-owned")
1190            .build();
1191        assert_eq!(
1192            transformed.definition().input_schema["properties"],
1193            serde_json::json!({})
1194        );
1195        assert_eq!(
1196            transformed.definition().input_schema["required"],
1197            serde_json::json!([])
1198        );
1199    }
1200
1201    #[test]
1202    fn transform_schema_applies_type_override() {
1203        let tool = SearchToolFixture::new("s");
1204        let transformed = TransformedTool::from_tool(tool)
1205            .transform_arg(
1206                "q",
1207                ArgTransform::new().type_schema(serde_json::json!({"type": "number"})),
1208            )
1209            .build();
1210
1211        let def = transformed.definition();
1212        let q_schema = &def.input_schema["properties"]["q"];
1213        assert_eq!(q_schema["type"], "number");
1214    }
1215
1216    // ── Schema transform: default value ──────────────────────────────
1217
1218    #[test]
1219    fn transform_schema_applies_default_value() {
1220        let tool = SearchToolFixture::new("s");
1221        let transformed = TransformedTool::from_tool(tool)
1222            .transform_arg("n", ArgTransform::new().default_int(25))
1223            .build();
1224
1225        let def = transformed.definition();
1226        let n_schema = &def.input_schema["properties"]["n"];
1227        assert_eq!(n_schema["default"], 25);
1228    }
1229
1230    // ── Schema rename updates required array ─────────────────────────
1231
1232    #[test]
1233    fn transform_schema_rename_updates_required() {
1234        let tool = SearchToolFixture::new("s");
1235        let transformed = TransformedTool::from_tool(tool)
1236            .rename_arg("q", "query")
1237            .build();
1238
1239        let def = transformed.definition();
1240        let required = def.input_schema["required"].as_array().unwrap();
1241        assert!(required.iter().any(|v| v == "query"));
1242        assert!(!required.iter().any(|v| v == "q"));
1243    }
1244
1245    // ── Schema hide removes from required ────────────────────────────
1246
1247    #[test]
1248    fn transform_schema_hide_removes_from_required() {
1249        // Make a tool where "q" is required, then hide it
1250        let tool = SearchToolFixture::new("s");
1251        let transformed = TransformedTool::from_tool(tool)
1252            .hide_arg("q", "default-query")
1253            .build();
1254
1255        let def = transformed.definition();
1256        let required = def.input_schema["required"].as_array().unwrap();
1257        assert!(!required.iter().any(|v| v == "q"));
1258    }
1259
1260    // ── Combined transforms ──────────────────────────────────────────
1261
1262    #[test]
1263    fn combined_rename_description_default() {
1264        let tool = SearchToolFixture::new("search");
1265        let transformed = TransformedTool::from_tool(tool)
1266            .transform_arg(
1267                "n",
1268                ArgTransform::new()
1269                    .name("limit")
1270                    .description("Max results")
1271                    .default_int(10),
1272            )
1273            .build();
1274
1275        let def = transformed.definition();
1276        let props = def.input_schema["properties"].as_object().unwrap();
1277        assert!(!props.contains_key("n"));
1278        let limit = props.get("limit").unwrap();
1279        assert_eq!(limit["description"], "Max results");
1280        assert_eq!(limit["default"], 10);
1281    }
1282
1283    // ── build_definition preserves parent metadata ───────────────────
1284
1285    #[test]
1286    fn build_definition_preserves_parent_output_schema() {
1287        struct ToolWithOutputSchema;
1288        impl ToolHandler for ToolWithOutputSchema {
1289            fn definition(&self) -> Tool {
1290                Tool {
1291                    name: "parent".to_string(),
1292                    description: None,
1293                    input_schema: serde_json::json!({"type": "object"}),
1294                    output_schema: Some(serde_json::json!({"type": "string"})),
1295                    icon: None,
1296                    version: Some("2.0".to_string()),
1297                    tags: vec!["tag1".to_string()],
1298                    annotations: None,
1299                }
1300            }
1301            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1302                Ok(vec![])
1303            }
1304        }
1305
1306        let transformed = TransformedTool::from_tool(ToolWithOutputSchema)
1307            .name("child")
1308            .build();
1309        let def = transformed.definition();
1310        assert_eq!(
1311            def.output_schema,
1312            Some(serde_json::json!({"type": "string"}))
1313        );
1314        assert_eq!(def.version, Some("2.0".to_string()));
1315        assert_eq!(def.tags, vec!["tag1".to_string()]);
1316    }
1317
1318    // ── transform_schema with non-object schema ──────────────────────
1319
1320    #[test]
1321    fn transform_schema_non_object_returned_as_is() {
1322        struct ArraySchemaTool;
1323        impl ToolHandler for ArraySchemaTool {
1324            fn definition(&self) -> Tool {
1325                Tool {
1326                    name: "arr".to_string(),
1327                    description: None,
1328                    input_schema: serde_json::json!("not an object"),
1329                    output_schema: None,
1330                    icon: None,
1331                    version: None,
1332                    tags: vec![],
1333                    annotations: None,
1334                }
1335            }
1336            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1337                Ok(vec![])
1338            }
1339        }
1340
1341        let transformed = TransformedTool::from_tool(ArraySchemaTool)
1342            .rename_arg("x", "y")
1343            .build();
1344        let def = transformed.definition();
1345        // Schema is returned as-is since it's not an object
1346        assert_eq!(def.input_schema, serde_json::json!("not an object"));
1347    }
1348
1349    // ── Schema without properties or required ────────────────────────
1350
1351    #[test]
1352    fn transform_schema_adds_properties_and_required_if_missing() {
1353        struct MinimalSchemaTool;
1354        impl ToolHandler for MinimalSchemaTool {
1355            fn definition(&self) -> Tool {
1356                Tool {
1357                    name: "min".to_string(),
1358                    description: None,
1359                    input_schema: serde_json::json!({"type": "object"}),
1360                    output_schema: None,
1361                    icon: None,
1362                    version: None,
1363                    tags: vec![],
1364                    annotations: None,
1365                }
1366            }
1367            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1368                Ok(vec![])
1369            }
1370        }
1371
1372        let transformed = TransformedTool::from_tool(MinimalSchemaTool).build();
1373        let def = transformed.definition();
1374        assert!(def.input_schema["properties"].is_object());
1375        assert!(def.input_schema["required"].is_array());
1376    }
1377
1378    // ── TransformedTool call with hidden defaults ─────────────────────
1379
1380    #[test]
1381    fn transformed_tool_call_injects_hidden_defaults() {
1382        let tool = SearchToolFixture::new("search");
1383        let transformed = TransformedTool::from_tool(tool)
1384            .rename_arg("q", "query")
1385            .hide_arg("n", 5)
1386            .build();
1387
1388        let cx = asupersync::Cx::for_testing();
1389        let ctx = McpContext::new(cx, 1);
1390        let result = transformed
1391            .call(&ctx, serde_json::json!({"query": "test"}))
1392            .unwrap();
1393        // The result should contain the search output with mapped args
1394        assert_eq!(result.len(), 1);
1395        if let Content::Text { text } = &result[0] {
1396            assert!(text.contains("\"n\":5"));
1397            assert!(text.contains("\"q\":\"test\""));
1398        } else {
1399            panic!("expected text content");
1400        }
1401    }
1402
1403    // ── transform_arg with no-op transform ───────────────────────────
1404
1405    #[test]
1406    fn transform_arg_with_noop_keeps_original() {
1407        let tool = SearchToolFixture::new("search");
1408        let transformed = TransformedTool::from_tool(tool)
1409            .transform_arg("q", ArgTransform::new())
1410            .build();
1411
1412        let def = transformed.definition();
1413        let props = def.input_schema["properties"].as_object().unwrap();
1414        // q should still exist unchanged
1415        assert!(props.contains_key("q"));
1416    }
1417
1418    // ── transform_arg for non-existent arg ───────────────────────────
1419
1420    #[test]
1421    fn transform_arg_for_nonexistent_arg_is_ignored() {
1422        let tool = SearchToolFixture::new("search");
1423        let transformed = TransformedTool::from_tool(tool)
1424            .rename_arg("nonexistent", "renamed")
1425            .build();
1426
1427        let def = transformed.definition();
1428        let props = def.input_schema["properties"].as_object().unwrap();
1429        // Original args should be untouched
1430        assert!(props.contains_key("q"));
1431        assert!(props.contains_key("n"));
1432        // Renamed nonexistent shouldn't appear
1433        assert!(!props.contains_key("renamed"));
1434    }
1435
1436    #[test]
1437    fn call_async_delegates_with_mapped_args() {
1438        use fastmcp_core::block_on;
1439
1440        let tool = SearchToolFixture::new("search");
1441        let transformed = TransformedTool::from_tool(tool)
1442            .rename_arg("q", "query")
1443            .hide_arg("n", 7)
1444            .build();
1445
1446        let cx = asupersync::Cx::for_testing();
1447        let ctx = McpContext::new(cx, 1);
1448        let result = block_on(transformed.call_async(&ctx, serde_json::json!({"query": "async"})));
1449        let content = result.unwrap();
1450        assert_eq!(content.len(), 1);
1451        if let Content::Text { text } = &content[0] {
1452            assert!(text.contains("\"q\":\"async\""));
1453            assert!(text.contains("\"n\":7"));
1454        } else {
1455            panic!("expected text content");
1456        }
1457    }
1458
1459    #[test]
1460    fn transform_arguments_no_value_no_default_not_hidden_skipped() {
1461        let tool = SearchToolFixture::new("search");
1462        let transformed = TransformedTool::from_tool(tool)
1463            .transform_arg("n", ArgTransform::new().description("ignored desc"))
1464            .build();
1465
1466        // Don't supply "n" at all - should skip it (no default, not hidden)
1467        let result = transformed
1468            .transform_arguments(serde_json::json!({"q": "hello"}))
1469            .unwrap();
1470        let obj = result.as_object().unwrap();
1471        assert_eq!(obj.get("q").unwrap(), "hello");
1472        assert!(
1473            obj.get("n").is_none(),
1474            "missing arg without default should be skipped"
1475        );
1476    }
1477
1478    #[test]
1479    fn transform_arguments_default_used_without_hide() {
1480        let tool = SearchToolFixture::new("search");
1481        let transformed = TransformedTool::from_tool(tool)
1482            .transform_arg("n", ArgTransform::new().default_int(99))
1483            .build();
1484
1485        // Don't supply "n" - default should kick in even though not hidden
1486        let result = transformed
1487            .transform_arguments(serde_json::json!({"q": "test"}))
1488            .unwrap();
1489        let obj = result.as_object().unwrap();
1490        assert_eq!(obj.get("n").unwrap(), 99);
1491    }
1492
1493    #[test]
1494    fn build_definition_parent_no_description_returns_none() {
1495        struct NoDescTool;
1496        impl ToolHandler for NoDescTool {
1497            fn definition(&self) -> Tool {
1498                Tool {
1499                    name: "nodesc".to_string(),
1500                    description: None,
1501                    input_schema: serde_json::json!({"type": "object"}),
1502                    output_schema: None,
1503                    icon: None,
1504                    version: None,
1505                    tags: vec![],
1506                    annotations: None,
1507                }
1508            }
1509            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1510                Ok(vec![])
1511            }
1512        }
1513
1514        let transformed = TransformedTool::from_tool(NoDescTool).build();
1515        assert!(transformed.definition().description.is_none());
1516    }
1517
1518    #[test]
1519    fn transform_schema_preserves_unrenamed_in_required() {
1520        // Create a tool with two required args; rename only one
1521        struct TwoReqTool;
1522        impl ToolHandler for TwoReqTool {
1523            fn definition(&self) -> Tool {
1524                Tool {
1525                    name: "two".to_string(),
1526                    description: None,
1527                    input_schema: serde_json::json!({
1528                        "type": "object",
1529                        "properties": {
1530                            "a": {"type": "string"},
1531                            "b": {"type": "string"}
1532                        },
1533                        "required": ["a", "b"]
1534                    }),
1535                    output_schema: None,
1536                    icon: None,
1537                    version: None,
1538                    tags: vec![],
1539                    annotations: None,
1540                }
1541            }
1542            fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
1543                Ok(vec![])
1544            }
1545        }
1546
1547        let transformed = TransformedTool::from_tool(TwoReqTool)
1548            .rename_arg("a", "alpha")
1549            .build();
1550        let def = transformed.definition();
1551        let required = def.input_schema["required"].as_array().unwrap();
1552        assert!(
1553            required.iter().any(|v| v == "alpha"),
1554            "renamed arg in required"
1555        );
1556        assert!(
1557            required.iter().any(|v| v == "b"),
1558            "unrenamed arg still in required"
1559        );
1560        assert!(
1561            !required.iter().any(|v| v == "a"),
1562            "old name removed from required"
1563        );
1564    }
1565
1566    #[test]
1567    fn type_schema_replaces_entire_property() {
1568        let tool = SearchToolFixture::new("s");
1569        let transformed = TransformedTool::from_tool(tool)
1570            .transform_arg(
1571                "q",
1572                ArgTransform::new()
1573                    .type_schema(serde_json::json!({"type": "array", "items": {"type": "string"}})),
1574            )
1575            .build();
1576
1577        let def = transformed.definition();
1578        let q_schema = &def.input_schema["properties"]["q"];
1579        // Should have the new type, not the old "string"
1580        assert_eq!(q_schema["type"], "array");
1581        assert!(q_schema["items"].is_object());
1582        // The old description should NOT be present (type_schema replaces entirely)
1583        assert!(q_schema.get("description").is_none());
1584    }
1585
1586    struct FinalAwareParent {
1587        recorded: std::sync::Arc<std::sync::Mutex<Option<serde_json::Value>>>,
1588        resume_hook_invoked: std::sync::Arc<std::sync::Mutex<bool>>,
1589    }
1590
1591    impl FinalAwareParent {
1592        fn new() -> Self {
1593            Self {
1594                recorded: std::sync::Arc::new(std::sync::Mutex::new(None)),
1595                resume_hook_invoked: std::sync::Arc::new(std::sync::Mutex::new(false)),
1596            }
1597        }
1598
1599        fn search_schema() -> serde_json::Value {
1600            serde_json::json!({
1601                "type": "object",
1602                "properties": {
1603                    "q": { "type": "string" },
1604                    "n": { "type": "integer" }
1605                },
1606                "required": ["q"]
1607            })
1608        }
1609    }
1610
1611    impl ToolHandler for FinalAwareParent {
1612        fn definition(&self) -> Tool {
1613            Tool {
1614                name: "search".to_string(),
1615                description: Some("Search tool".to_string()),
1616                input_schema: Self::search_schema(),
1617                output_schema: None,
1618                icon: None,
1619                version: None,
1620                tags: vec![],
1621                annotations: None,
1622            }
1623        }
1624
1625        fn timeout(&self) -> Option<Duration> {
1626            Some(Duration::from_secs(3))
1627        }
1628
1629        fn declares_final_mrtr(&self) -> bool {
1630            true
1631        }
1632
1633        fn final_title(&self) -> Option<&str> {
1634            Some("Search")
1635        }
1636
1637        fn final_definition(&self) -> Option<FinalTool> {
1638            Some(FinalTool {
1639                name: "search".to_string(),
1640                title: Some("Search".to_string()),
1641                description: Some("Search tool".to_string()),
1642                icons: None,
1643                input_schema: Self::search_schema(),
1644                output_schema: None,
1645                annotations: None,
1646                meta: None,
1647            })
1648        }
1649
1650        fn call(&self, _ctx: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
1651            *self
1652                .recorded
1653                .lock()
1654                .expect("final-aware parent argument log is not poisoned") = Some(arguments);
1655            Ok(vec![Content::text("legacy")])
1656        }
1657
1658        fn call_final(
1659            &self,
1660            _ctx: &McpContext,
1661            arguments: serde_json::Value,
1662        ) -> McpResult<CompleteResult<FinalCallToolResult>> {
1663            *self
1664                .recorded
1665                .lock()
1666                .expect("final-aware parent argument log is not poisoned") = Some(arguments);
1667            crate::handler::promote_legacy_tool_content(vec![Content::text("final")])
1668        }
1669
1670        fn call_final_outcome_async_resuming_in_request<'a>(
1671            &'a self,
1672            ctx: &'a McpContext,
1673            _request_cx: &'a Cx,
1674            arguments: serde_json::Value,
1675            _resume_inputs: Option<&'a MrtrCompletedInputs>,
1676        ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
1677            Box::pin(async move {
1678                *self
1679                    .resume_hook_invoked
1680                    .lock()
1681                    .expect("final-aware parent resume log is not poisoned") = true;
1682                match self.call_final(ctx, arguments) {
1683                    Ok(result) => Outcome::Ok(FinalToolOutcome::Complete(result)),
1684                    Err(error) => Outcome::Err(error),
1685                }
1686            })
1687        }
1688    }
1689
1690    #[test]
1691    fn transformed_tool_forwards_timeout_and_final_definition() {
1692        let transformed = TransformedTool::from_tool(FinalAwareParent::new())
1693            .name("semantic_search")
1694            .rename_arg("q", "query")
1695            .hide_arg("n", 10)
1696            .build();
1697
1698        assert_eq!(transformed.timeout(), Some(Duration::from_secs(3)));
1699        assert!(transformed.declares_final_mrtr());
1700        assert_eq!(transformed.final_title(), Some("Search"));
1701
1702        let final_definition = transformed
1703            .final_definition()
1704            .expect("parent final definition must survive the transform wrapper");
1705        assert_eq!(final_definition.name, "semantic_search");
1706        assert_eq!(final_definition.title.as_deref(), Some("Search"));
1707        let properties = final_definition.input_schema["properties"]
1708            .as_object()
1709            .expect("final input schema keeps object properties");
1710        assert!(
1711            properties.contains_key("query"),
1712            "final catalog must publish the renamed argument: {properties:?}"
1713        );
1714        assert!(
1715            !properties.contains_key("q"),
1716            "final catalog must not keep the unpublished original name: {properties:?}"
1717        );
1718        assert!(
1719            !properties.contains_key("n"),
1720            "final catalog must hide the dropped argument: {properties:?}"
1721        );
1722    }
1723
1724    #[test]
1725    fn transformed_tool_call_final_uses_parent_final_hook_with_mapped_args() {
1726        let parent = FinalAwareParent::new();
1727        let recorded = std::sync::Arc::clone(&parent.recorded);
1728        let transformed = TransformedTool::from_tool(parent)
1729            .name("semantic_search")
1730            .rename_arg("q", "query")
1731            .hide_arg("n", 10)
1732            .build();
1733
1734        let cx = asupersync::Cx::for_testing();
1735        let ctx = McpContext::new(cx, 1);
1736        let result = transformed
1737            .call_final(&ctx, serde_json::json!({"query": "test"}))
1738            .expect("transformed final call must succeed");
1739        match result.payload.content.as_slice() {
1740            [ContentBlock::Text { text, .. }] => {
1741                assert_eq!(
1742                    text, "final",
1743                    "TransformedTool must invoke the parent final hook instead of promoting call()"
1744                );
1745            }
1746            other => panic!("expected one final text block, got {other:?}"),
1747        }
1748        let recorded = recorded
1749            .lock()
1750            .expect("final-aware parent argument log is not poisoned")
1751            .clone()
1752            .expect("parent final hook must observe mapped arguments");
1753        assert_eq!(recorded["q"], "test");
1754        assert_eq!(recorded["n"], 10);
1755        assert!(
1756            recorded.get("query").is_none(),
1757            "parent must see original names, not the published aliases: {recorded}"
1758        );
1759    }
1760
1761    #[test]
1762    fn transformed_tool_call_final_does_not_promote_legacy_call() {
1763        let parent = FinalAwareParent::new();
1764        let transformed = TransformedTool::from_tool(parent)
1765            .rename_arg("q", "query")
1766            .build();
1767
1768        let cx = asupersync::Cx::for_testing();
1769        let ctx = McpContext::new(cx, 1);
1770        let legacy = transformed
1771            .call(&ctx, serde_json::json!({"query": "legacy-path"}))
1772            .expect("legacy call still works");
1773        match &legacy[0] {
1774            Content::Text { text, .. } => assert_eq!(text, "legacy"),
1775            other => panic!("expected legacy text content, got {other:?}"),
1776        }
1777
1778        let final_result = transformed
1779            .call_final(&ctx, serde_json::json!({"query": "final-path"}))
1780            .expect("final call still works");
1781        match final_result.payload.content.as_slice() {
1782            [ContentBlock::Text { text, .. }] => {
1783                assert_eq!(text, "final");
1784                assert_ne!(
1785                    text, "legacy",
1786                    "the planted dimension is which parent hook ran"
1787                );
1788            }
1789            other => panic!("expected one final text block, got {other:?}"),
1790        }
1791    }
1792
1793    #[test]
1794    fn transformed_tool_forwards_mrtr_resume_hook() {
1795        let parent = FinalAwareParent::new();
1796        let resume_hook_invoked = std::sync::Arc::clone(&parent.resume_hook_invoked);
1797        let transformed = TransformedTool::from_tool(parent)
1798            .rename_arg("q", "query")
1799            .build();
1800
1801        let cx = asupersync::Cx::for_testing();
1802        let ctx = McpContext::new(cx.clone(), 1);
1803        let outcome = block_on(transformed.call_final_outcome_async_resuming_in_request(
1804            &ctx,
1805            &cx,
1806            serde_json::json!({"query": "resume"}),
1807            None,
1808        ));
1809        match outcome {
1810            Outcome::Ok(FinalToolOutcome::Complete(result)) => {
1811                match result.payload.content.as_slice() {
1812                    [ContentBlock::Text { text, .. }] => {
1813                        assert_eq!(text, "final");
1814                    }
1815                    other => panic!("expected one final text block, got {other:?}"),
1816                }
1817            }
1818            other => panic!(
1819                "expected a complete final outcome, got {}",
1820                match other {
1821                    Outcome::Ok(_) => "Ok(non-complete)",
1822                    Outcome::Err(_) => "Err",
1823                    Outcome::Cancelled(_) => "Cancelled",
1824                    Outcome::Panicked(_) => "Panicked",
1825                }
1826            ),
1827        }
1828        assert!(
1829            *resume_hook_invoked
1830                .lock()
1831                .expect("final-aware parent resume log is not poisoned"),
1832            "TransformedTool must call the parent MRTR resume hook instead of dropping resume inputs"
1833        );
1834    }
1835}