@@ -635,6 +635,178 @@ def get_scores() -> dict[str, int]:
635635 assert result == expected
636636
637637
638+ class TestToolMetadata :
639+ """Test tool metadata functionality."""
640+
641+ def test_add_tool_with_metadata (self ):
642+ """Test adding a tool with metadata via ToolManager."""
643+
644+ def process_data (input_data : str ) -> str :
645+ """Process some data."""
646+ return f"Processed: { input_data } "
647+
648+ metadata = {"ui" : {"type" : "form" , "fields" : ["input" ]}, "version" : "1.0" }
649+
650+ manager = ToolManager ()
651+ tool = manager .add_tool (process_data , meta = metadata )
652+
653+ assert tool .meta is not None
654+ assert tool .meta == metadata
655+ assert tool .meta ["ui" ]["type" ] == "form"
656+ assert tool .meta ["version" ] == "1.0"
657+
658+ def test_add_tool_without_metadata (self ):
659+ """Test that tools without metadata have None as meta value."""
660+
661+ def simple_tool (x : int ) -> int :
662+ """Simple tool."""
663+ return x * 2
664+
665+ manager = ToolManager ()
666+ tool = manager .add_tool (simple_tool )
667+
668+ assert tool .meta is None
669+
670+ @pytest .mark .anyio
671+ async def test_metadata_in_fastmcp_decorator (self ):
672+ """Test that metadata is correctly added via FastMCP.tool decorator."""
673+
674+ app = FastMCP ()
675+
676+ metadata = {"client" : {"ui_component" : "file_picker" }, "priority" : "high" }
677+
678+ @app .tool (meta = metadata )
679+ def upload_file (filename : str ) -> str :
680+ """Upload a file."""
681+ return f"Uploaded: { filename } "
682+
683+ # Get the tool from the tool manager
684+ tool = app ._tool_manager .get_tool ("upload_file" )
685+ assert tool is not None
686+ assert tool .meta is not None
687+ assert tool .meta == metadata
688+ assert tool .meta ["client" ]["ui_component" ] == "file_picker"
689+ assert tool .meta ["priority" ] == "high"
690+
691+ @pytest .mark .anyio
692+ async def test_metadata_in_list_tools (self ):
693+ """Test that metadata is included in MCPTool when listing tools."""
694+
695+ app = FastMCP ()
696+
697+ metadata = {
698+ "ui" : {"input_type" : "textarea" , "rows" : 5 },
699+ "tags" : ["text" , "processing" ],
700+ }
701+
702+ @app .tool (meta = metadata )
703+ def analyze_text (text : str ) -> dict [str , Any ]:
704+ """Analyze text content."""
705+ return {"length" : len (text ), "words" : len (text .split ())}
706+
707+ tools = await app .list_tools ()
708+ assert len (tools ) == 1
709+ assert tools [0 ].meta is not None
710+ assert tools [0 ].meta == metadata
711+
712+ @pytest .mark .anyio
713+ async def test_multiple_tools_with_different_metadata (self ):
714+ """Test multiple tools with different metadata values."""
715+
716+ app = FastMCP ()
717+
718+ metadata1 = {"ui" : "form" , "version" : 1 }
719+ metadata2 = {"ui" : "picker" , "experimental" : True }
720+
721+ @app .tool (meta = metadata1 )
722+ def tool1 (x : int ) -> int :
723+ """First tool."""
724+ return x
725+
726+ @app .tool (meta = metadata2 )
727+ def tool2 (y : str ) -> str :
728+ """Second tool."""
729+ return y
730+
731+ @app .tool ()
732+ def tool3 (z : bool ) -> bool :
733+ """Third tool without metadata."""
734+ return z
735+
736+ tools = await app .list_tools ()
737+ assert len (tools ) == 3
738+
739+ # Find tools by name and check metadata
740+ tools_by_name = {t .name : t for t in tools }
741+
742+ assert tools_by_name ["tool1" ].meta == metadata1
743+ assert tools_by_name ["tool2" ].meta == metadata2
744+ assert tools_by_name ["tool3" ].meta is None
745+
746+ def test_metadata_with_complex_structure (self ):
747+ """Test metadata with complex nested structures."""
748+
749+ def complex_tool (data : str ) -> str :
750+ """Tool with complex metadata."""
751+ return data
752+
753+ metadata = {
754+ "ui" : {
755+ "components" : [
756+ {"type" : "input" , "name" : "field1" , "validation" : {"required" : True , "minLength" : 5 }},
757+ {"type" : "select" , "name" : "field2" , "options" : ["a" , "b" , "c" ]},
758+ ],
759+ "layout" : {"columns" : 2 , "responsive" : True },
760+ },
761+ "permissions" : ["read" , "write" ],
762+ "tags" : ["data-processing" , "user-input" ],
763+ "version" : 2 ,
764+ }
765+
766+ manager = ToolManager ()
767+ tool = manager .add_tool (complex_tool , meta = metadata )
768+
769+ assert tool .meta is not None
770+ assert tool .meta ["ui" ]["components" ][0 ]["validation" ]["minLength" ] == 5
771+ assert tool .meta ["ui" ]["layout" ]["columns" ] == 2
772+ assert "read" in tool .meta ["permissions" ]
773+ assert "data-processing" in tool .meta ["tags" ]
774+
775+ def test_metadata_empty_dict (self ):
776+ """Test that empty dict metadata is preserved."""
777+
778+ def tool_with_empty_meta (x : int ) -> int :
779+ """Tool with empty metadata."""
780+ return x
781+
782+ manager = ToolManager ()
783+ tool = manager .add_tool (tool_with_empty_meta , meta = {})
784+
785+ assert tool .meta is not None
786+ assert tool .meta == {}
787+
788+ @pytest .mark .anyio
789+ async def test_metadata_with_annotations (self ):
790+ """Test that metadata and annotations can coexist."""
791+
792+ app = FastMCP ()
793+
794+ metadata = {"custom" : "value" }
795+ annotations = ToolAnnotations (title = "Combined Tool" , readOnlyHint = True )
796+
797+ @app .tool (meta = metadata , annotations = annotations )
798+ def combined_tool (data : str ) -> str :
799+ """Tool with both metadata and annotations."""
800+ return data
801+
802+ tools = await app .list_tools ()
803+ assert len (tools ) == 1
804+ assert tools [0 ].meta == metadata
805+ assert tools [0 ].annotations is not None
806+ assert tools [0 ].annotations .title == "Combined Tool"
807+ assert tools [0 ].annotations .readOnlyHint is True
808+
809+
638810class TestRemoveTools :
639811 """Test tool removal functionality in the tool manager."""
640812
0 commit comments