Jeremiah Lowin commited on
Commit
41cf7e2
·
1 Parent(s): 1f34d30

Allow setting type with ArgTransform

Browse files
src/fastmcp/tools/tool_transform.py CHANGED
@@ -112,6 +112,7 @@ from mcp.types import EmbeddedResource, ImageContent, TextContent, ToolAnnotatio
112
 
113
  from fastmcp.tools.tool import ParsedFunction, Tool
114
  from fastmcp.utilities.logging import get_logger
 
115
 
116
  if TYPE_CHECKING:
117
  pass
@@ -193,6 +194,7 @@ class ArgTransform:
193
  name: New name for the argument. Use None to keep original name, or ... for no change.
194
  description: New description for the argument. Use None to remove description, or ... for no change.
195
  default: New default value for the argument. Use ... for no change.
 
196
  drop: If True, remove this argument from the transformed tool's schema.
197
 
198
  Examples:
@@ -205,16 +207,20 @@ class ArgTransform:
205
  # Add a default value (makes argument optional)
206
  ArgTransform(default=42)
207
 
 
 
 
208
  # Drop the argument entirely
209
  ArgTransform(drop=True)
210
 
211
  # Combine multiple transformations
212
- ArgTransform(name="new_name", description="New desc", default=None)
213
  """
214
 
215
  name: str | None | EllipsisType = ...
216
  description: str | None | EllipsisType = ...
217
  default: Any | EllipsisType = ...
 
218
  drop: bool = False
219
 
220
 
@@ -259,9 +265,18 @@ class TransformedTool(Tool):
259
  """
260
  from fastmcp.tools.tool import _convert_to_content
261
 
 
 
 
 
 
 
 
 
 
262
  token = _current_tool.set(self)
263
  try:
264
- result = await self.fn(**arguments)
265
  return _convert_to_content(result, serializer=self.serializer)
266
  finally:
267
  _current_tool.reset(token)
@@ -357,51 +372,20 @@ class TransformedTool(Tool):
357
  f"Function declares: {', '.join(sorted(fn_params))}"
358
  )
359
 
360
- # The function defines the final schema
361
- final_schema = parsed_fn.parameters.copy()
362
- # Inherit descriptions from transformed parent where possible
363
- fn_props = final_schema.get("properties", {})
364
- transformed_props = schema.get("properties", {})
365
-
366
- for param_name in fn_props:
367
- if param_name in transformed_props:
368
- parent_desc = transformed_props[param_name].get("description")
369
- if parent_desc and "description" not in fn_props[param_name]:
370
- fn_props[param_name]["description"] = parent_desc
371
  else:
372
  # With **kwargs, function can access all transformed params
373
- # Function params override transformed params if they overlap
374
  # No validation needed - kwargs makes everything accessible
375
 
376
- # Function accepts **kwargs, so use transformed schema as base
377
- # and let function override specific parameters
378
- fn_props = parsed_fn.parameters.get("properties", {})
379
- fn_required = set(parsed_fn.parameters.get("required", []))
380
-
381
- final_props = schema.get("properties", {}).copy()
382
- final_required = set(schema.get("required", []))
383
-
384
- # Override with function's parameters
385
- for param_name, param_schema in fn_props.items():
386
- # Inherit description from transformed parent if function doesn't provide one
387
- if param_name in final_props and "description" not in param_schema:
388
- param_schema = param_schema.copy()
389
- param_schema["description"] = final_props[param_name].get(
390
- "description"
391
- )
392
-
393
- final_props[param_name] = param_schema
394
-
395
- if param_name in fn_required:
396
- final_required.add(param_name)
397
- else:
398
- final_required.discard(param_name)
399
-
400
- final_schema = {
401
- "type": "object",
402
- "properties": final_props,
403
- "required": list(final_required),
404
- }
405
 
406
  # Additional validation: check for naming conflicts after transformation
407
  if transform_args:
@@ -578,11 +562,76 @@ class TransformedTool(Tool):
578
  if transform.default is not ...:
579
  new_schema["default"] = transform.default
580
  is_required = False
 
 
 
 
 
581
 
582
  return new_name, new_schema, is_required # type: ignore[return-value]
583
 
584
  raise ValueError(f"Invalid transform: {transform}")
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  @staticmethod
587
  def _function_has_kwargs(fn: Callable[..., Any]) -> bool:
588
  """Check if function accepts **kwargs.
 
112
 
113
  from fastmcp.tools.tool import ParsedFunction, Tool
114
  from fastmcp.utilities.logging import get_logger
115
+ from fastmcp.utilities.types import get_cached_typeadapter
116
 
117
  if TYPE_CHECKING:
118
  pass
 
194
  name: New name for the argument. Use None to keep original name, or ... for no change.
195
  description: New description for the argument. Use None to remove description, or ... for no change.
196
  default: New default value for the argument. Use ... for no change.
197
+ type: New type for the argument. Use ... for no change.
198
  drop: If True, remove this argument from the transformed tool's schema.
199
 
200
  Examples:
 
207
  # Add a default value (makes argument optional)
208
  ArgTransform(default=42)
209
 
210
+ # Change the type
211
+ ArgTransform(type=str)
212
+
213
  # Drop the argument entirely
214
  ArgTransform(drop=True)
215
 
216
  # Combine multiple transformations
217
+ ArgTransform(name="new_name", description="New desc", default=None, type=int)
218
  """
219
 
220
  name: str | None | EllipsisType = ...
221
  description: str | None | EllipsisType = ...
222
  default: Any | EllipsisType = ...
223
+ type: Any | EllipsisType = ...
224
  drop: bool = False
225
 
226
 
 
265
  """
266
  from fastmcp.tools.tool import _convert_to_content
267
 
268
+ # Fill in missing arguments with schema defaults to ensure
269
+ # ArgTransform defaults take precedence over function defaults
270
+ filled_arguments = arguments.copy()
271
+ properties = self.parameters.get("properties", {})
272
+
273
+ for param_name, param_schema in properties.items():
274
+ if param_name not in filled_arguments and "default" in param_schema:
275
+ filled_arguments[param_name] = param_schema["default"]
276
+
277
  token = _current_tool.set(self)
278
  try:
279
+ result = await self.fn(**filled_arguments)
280
  return _convert_to_content(result, serializer=self.serializer)
281
  finally:
282
  _current_tool.reset(token)
 
372
  f"Function declares: {', '.join(sorted(fn_params))}"
373
  )
374
 
375
+ # ArgTransform takes precedence over function signature
376
+ # Start with function schema as base, then override with transformed schema
377
+ final_schema = cls._merge_schema_with_precedence(
378
+ parsed_fn.parameters, schema
379
+ )
 
 
 
 
 
 
380
  else:
381
  # With **kwargs, function can access all transformed params
382
+ # ArgTransform takes precedence over function signature
383
  # No validation needed - kwargs makes everything accessible
384
 
385
+ # Start with function schema as base, then override with transformed schema
386
+ final_schema = cls._merge_schema_with_precedence(
387
+ parsed_fn.parameters, schema
388
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  # Additional validation: check for naming conflicts after transformation
391
  if transform_args:
 
562
  if transform.default is not ...:
563
  new_schema["default"] = transform.default
564
  is_required = False
565
+ if transform.type is not ...:
566
+ # Use TypeAdapter to get proper JSON schema for the type
567
+ type_schema = get_cached_typeadapter(transform.type).json_schema()
568
+ # Update the schema with the type information from TypeAdapter
569
+ new_schema.update(type_schema)
570
 
571
  return new_name, new_schema, is_required # type: ignore[return-value]
572
 
573
  raise ValueError(f"Invalid transform: {transform}")
574
 
575
+ @staticmethod
576
+ def _merge_schema_with_precedence(
577
+ base_schema: dict[str, Any], override_schema: dict[str, Any]
578
+ ) -> dict[str, Any]:
579
+ """Merge two schemas, with the override schema taking precedence.
580
+
581
+ Args:
582
+ base_schema: Base schema to start with
583
+ override_schema: Schema that takes precedence for overlapping properties
584
+
585
+ Returns:
586
+ Merged schema with override taking precedence
587
+ """
588
+ merged_props = base_schema.get("properties", {}).copy()
589
+ merged_required = set(base_schema.get("required", []))
590
+
591
+ override_props = override_schema.get("properties", {})
592
+ override_required = set(override_schema.get("required", []))
593
+
594
+ # Override properties
595
+ for param_name, param_schema in override_props.items():
596
+ if param_name in merged_props:
597
+ # Merge the schemas, with override taking precedence
598
+ base_param = merged_props[param_name].copy()
599
+ base_param.update(param_schema)
600
+ merged_props[param_name] = base_param
601
+ else:
602
+ merged_props[param_name] = param_schema.copy()
603
+
604
+ # Handle required parameters - override takes complete precedence
605
+ # Start with override's required set
606
+ final_required = override_required.copy()
607
+
608
+ # For parameters not in override, inherit base requirement status
609
+ # but only if they don't have a default in the final merged properties
610
+ for param_name in merged_required:
611
+ if param_name not in override_props:
612
+ # Parameter not mentioned in override, keep base requirement status
613
+ final_required.add(param_name)
614
+ elif (
615
+ param_name in override_props
616
+ and "default" not in merged_props[param_name]
617
+ ):
618
+ # Parameter in override but no default, keep required if it was required in base
619
+ if param_name not in override_required:
620
+ # Override doesn't specify it as required, and it has no default,
621
+ # so inherit from base
622
+ final_required.add(param_name)
623
+
624
+ # Remove any parameters that have defaults (they become optional)
625
+ for param_name, param_schema in merged_props.items():
626
+ if "default" in param_schema:
627
+ final_required.discard(param_name)
628
+
629
+ return {
630
+ "type": "object",
631
+ "properties": merged_props,
632
+ "required": list(final_required),
633
+ }
634
+
635
  @staticmethod
636
  def _function_has_kwargs(fn: Callable[..., Any]) -> bool:
637
  """Check if function accepts **kwargs.
tests/tools/test_tool_transform.py CHANGED
@@ -1,9 +1,10 @@
1
  import re
2
- from typing import Annotated, Any
 
3
 
4
  import pytest
5
  from dirty_equals import IsList
6
- from pydantic import Field
7
  from rich import print # type: ignore
8
 
9
  from fastmcp import FastMCP
@@ -388,6 +389,219 @@ async def test_chaining_transformations(add_tool):
388
  assert "Chained:" in result[0].text # type: ignore
389
 
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  class TestProxy:
392
  @pytest.fixture
393
  def mcp_server(self) -> FastMCP:
 
1
  import re
2
+ from dataclasses import dataclass
3
+ from typing import Annotated, Any, TypedDict
4
 
5
  import pytest
6
  from dirty_equals import IsList
7
+ from pydantic import BaseModel, Field
8
  from rich import print # type: ignore
9
 
10
  from fastmcp import FastMCP
 
389
  assert "Chained:" in result[0].text # type: ignore
390
 
391
 
392
+ class MyModel(BaseModel):
393
+ x: int
394
+ y: str
395
+
396
+
397
+ @dataclass
398
+ class MyDataclass:
399
+ x: int
400
+ y: str
401
+
402
+
403
+ class MyTypedDict(TypedDict):
404
+ x: int
405
+ y: str
406
+
407
+
408
+ @pytest.mark.parametrize(
409
+ "py_type, json_type",
410
+ [
411
+ (int, "integer"),
412
+ (float, "number"),
413
+ (str, "string"),
414
+ (bool, "boolean"),
415
+ (list, "array"),
416
+ (list[int], "array"),
417
+ (dict, "object"),
418
+ (dict[str, int], "object"),
419
+ (MyModel, "object"),
420
+ (MyDataclass, "object"),
421
+ (MyTypedDict, "object"),
422
+ ],
423
+ )
424
+ def test_arg_transform_type_handling(add_tool, py_type, json_type):
425
+ """Test that ArgTransform type attribute gets applied to schema."""
426
+ new_tool = Tool.from_tool(
427
+ add_tool, transform_args={"old_x": ArgTransform(type=py_type)}
428
+ )
429
+
430
+ # Check that the type was changed in the schema
431
+ x_prop = get_property(new_tool, "old_x")
432
+ assert x_prop["type"] == json_type
433
+
434
+
435
+ def test_arg_transform_annotated_types(add_tool):
436
+ """Test that ArgTransform works with annotated types and complex types."""
437
+ from typing import Annotated
438
+
439
+ from pydantic import Field
440
+
441
+ # Test with Annotated types
442
+ tool = Tool.from_tool(
443
+ add_tool,
444
+ transform_args={
445
+ "old_x": ArgTransform(
446
+ type=Annotated[int, Field(description="An annotated integer")]
447
+ )
448
+ },
449
+ )
450
+
451
+ x_prop = get_property(tool, "old_x")
452
+ assert x_prop["type"] == "integer"
453
+ # The ArgTransform description should override the annotation description
454
+ # (since we didn't set a description in ArgTransform, it should use the original)
455
+
456
+ # Test with Annotated string that has constraints
457
+ tool2 = Tool.from_tool(
458
+ add_tool,
459
+ transform_args={
460
+ "old_x": ArgTransform(
461
+ type=Annotated[str, Field(min_length=1, max_length=10)]
462
+ )
463
+ },
464
+ )
465
+
466
+ x_prop2 = get_property(tool2, "old_x")
467
+ assert x_prop2["type"] == "string"
468
+ assert x_prop2["minLength"] == 1
469
+ assert x_prop2["maxLength"] == 10
470
+
471
+
472
+ def test_arg_transform_precedence_over_function_without_kwargs():
473
+ """Test that ArgTransform attributes take precedence over function signature (no **kwargs)."""
474
+
475
+ @Tool.from_function
476
+ def base(x: int, y: str = "default") -> str:
477
+ return f"{x}: {y}"
478
+
479
+ # Function signature says x: int with no default, y: str = "function_default"
480
+ # ArgTransform should override these
481
+ def custom_fn(x: str = "transform_default", y: int = 99) -> str:
482
+ return f"custom: {x}, {y}"
483
+
484
+ tool = Tool.from_tool(
485
+ base,
486
+ transform_fn=custom_fn,
487
+ transform_args={
488
+ "x": ArgTransform(type=str, default="transform_default"),
489
+ "y": ArgTransform(type=int, default=99),
490
+ },
491
+ )
492
+
493
+ # ArgTransform should take precedence
494
+ x_prop = get_property(tool, "x")
495
+ y_prop = get_property(tool, "y")
496
+
497
+ assert x_prop["type"] == "string" # ArgTransform type wins
498
+ assert x_prop["default"] == "transform_default" # ArgTransform default wins
499
+ assert y_prop["type"] == "integer" # ArgTransform type wins
500
+ assert y_prop["default"] == 99 # ArgTransform default wins
501
+
502
+ # Neither parameter should be required due to ArgTransform defaults
503
+ assert "x" not in tool.parameters["required"]
504
+ assert "y" not in tool.parameters["required"]
505
+
506
+
507
+ async def test_arg_transform_precedence_over_function_with_kwargs():
508
+ """Test that ArgTransform attributes take precedence over function signature (with **kwargs)."""
509
+
510
+ @Tool.from_function
511
+ def base(x: int, y: str = "base_default") -> str:
512
+ return f"{x}: {y}"
513
+
514
+ # Function signature has different types/defaults than ArgTransform
515
+ async def custom_fn(x: str = "function_default", **kwargs) -> str:
516
+ result = await forward(x=x, **kwargs)
517
+ return f"custom: {result}"
518
+
519
+ tool = Tool.from_tool(
520
+ base,
521
+ transform_fn=custom_fn,
522
+ transform_args={
523
+ "x": ArgTransform(type=int, default=42), # Different type and default
524
+ "y": ArgTransform(description="ArgTransform description"),
525
+ },
526
+ )
527
+
528
+ # ArgTransform should take precedence
529
+ x_prop = get_property(tool, "x")
530
+ y_prop = get_property(tool, "y")
531
+
532
+ assert x_prop["type"] == "integer" # ArgTransform type wins over function's str
533
+ assert x_prop["default"] == 42 # ArgTransform default wins over function's default
534
+ assert (
535
+ y_prop["description"] == "ArgTransform description"
536
+ ) # ArgTransform description
537
+
538
+ # x should not be required due to ArgTransform default
539
+ assert "x" not in tool.parameters["required"]
540
+
541
+ # Test it works at runtime
542
+ result = await tool.run(arguments={"y": "test"})
543
+ # Should use ArgTransform default of 42
544
+ assert "42: test" in result[0].text # type: ignore
545
+
546
+
547
+ def test_arg_transform_combined_attributes():
548
+ """Test that multiple ArgTransform attributes work together."""
549
+
550
+ @Tool.from_function
551
+ def base(param: int) -> str:
552
+ return str(param)
553
+
554
+ tool = Tool.from_tool(
555
+ base,
556
+ transform_args={
557
+ "param": ArgTransform(
558
+ name="renamed_param",
559
+ type=str,
560
+ description="New description",
561
+ default="default_value",
562
+ )
563
+ },
564
+ )
565
+
566
+ # Check all attributes were applied
567
+ assert "renamed_param" in tool.parameters["properties"]
568
+ assert "param" not in tool.parameters["properties"]
569
+
570
+ prop = get_property(tool, "renamed_param")
571
+ assert prop["type"] == "string"
572
+ assert prop["description"] == "New description"
573
+ assert prop["default"] == "default_value"
574
+ assert "renamed_param" not in tool.parameters["required"] # Has default
575
+
576
+
577
+ async def test_arg_transform_type_precedence_runtime():
578
+ """Test that ArgTransform type changes work correctly at runtime."""
579
+
580
+ @Tool.from_function
581
+ def base(x: int, y: int = 10) -> int:
582
+ return x + y
583
+
584
+ # Transform x to string type but keep same logic
585
+ async def custom_fn(x: str, y: int = 10) -> str:
586
+ # Convert string back to int for the original function
587
+ result = await forward_raw(x=int(x), y=y)
588
+ # Extract the text from the result
589
+ result_text = result[0].text
590
+ return f"String input '{x}' converted to result: {result_text}"
591
+
592
+ tool = Tool.from_tool(
593
+ base, transform_fn=custom_fn, transform_args={"x": ArgTransform(type=str)}
594
+ )
595
+
596
+ # Verify schema shows string type
597
+ assert get_property(tool, "x")["type"] == "string"
598
+
599
+ # Test it works with string input
600
+ result = await tool.run(arguments={"x": "5", "y": 3})
601
+ assert "String input '5'" in result[0].text # type: ignore
602
+ assert "result: 8" in result[0].text # type: ignore
603
+
604
+
605
  class TestProxy:
606
  @pytest.fixture
607
  def mcp_server(self) -> FastMCP: