"""Tests for fastmcp.utilities.components module.""" import warnings import pytest from pydantic import ValidationError from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool from fastmcp.utilities.components import ( FastMCPComponent, FastMCPMeta, _convert_set_default_none, get_fastmcp_metadata, ) class TestConvertSetDefaultNone: """Tests for the _convert_set_default_none helper function.""" def test_none_returns_empty_set(self): """Test that None returns an empty set.""" result = _convert_set_default_none(None) assert result == set() def test_set_returns_same_set(self): """Test that a set returns the same set.""" test_set = {"tag1", "tag2"} result = _convert_set_default_none(test_set) assert result == test_set def test_list_converts_to_set(self): """Test that a list converts to a set.""" test_list = ["tag1", "tag2", "tag1"] # Duplicate to test deduplication result = _convert_set_default_none(test_list) assert result == {"tag1", "tag2"} def test_tuple_converts_to_set(self): """Test that a tuple converts to a set.""" test_tuple = ("tag1", "tag2") result = _convert_set_default_none(test_tuple) assert result == {"tag1", "tag2"} class TestFastMCPComponent: """Tests for the FastMCPComponent class.""" @pytest.fixture def basic_component(self): """Create a basic component for testing.""" return FastMCPComponent( name="test_component", title="Test Component", description="A test component", tags={"test", "component"}, ) def test_initialization_with_minimal_params(self): """Test component initialization with minimal parameters.""" component = FastMCPComponent(name="minimal") assert component.name == "minimal" assert component.title is None assert component.description is None assert component.tags == set() assert component.meta is None def test_initialization_with_all_params(self): """Test component initialization with all parameters.""" meta = {"custom": "value"} component = FastMCPComponent( name="full", title="Full Component", description="A fully configured component", tags={"tag1", "tag2"}, meta=meta, ) assert component.name == "full" assert component.title == "Full Component" assert component.description == "A fully configured component" assert component.tags == {"tag1", "tag2"} assert component.meta == meta def test_key_property_without_custom_key(self, basic_component): """Test that key property returns name@version when no custom key is set.""" # Base component has no KEY_PREFIX, so key is just "name@version" (or "name@" for unversioned) assert basic_component.key == "test_component@" def test_get_meta_with_fastmcp_meta(self, basic_component): """Test get_meta always includes fastmcp meta.""" basic_component.meta = {"custom": "data"} basic_component.tags = {"tag2", "tag1"} # Unordered to test sorting result = basic_component.get_meta() assert result["custom"] == "data" assert "fastmcp" in result assert result["fastmcp"]["tags"] == ["tag1", "tag2"] # Should be sorted def test_get_meta_preserves_existing_fastmcp_meta(self): """Test that get_meta preserves existing fastmcp meta.""" component = FastMCPComponent( name="test", meta={"fastmcp": {"existing": "value"}}, tags={"new_tag"}, ) result = component.get_meta() assert result is not None assert result["fastmcp"]["existing"] == "value" assert result["fastmcp"]["tags"] == ["new_tag"] def test_get_meta_returns_dict_with_fastmcp_when_empty(self): """Test that get_meta returns dict with fastmcp meta even when no custom meta.""" component = FastMCPComponent(name="test") result = component.get_meta() assert result is not None assert "fastmcp" in result assert result["fastmcp"]["tags"] == [] def test_get_meta_includes_version(self): """Test that get_meta includes version when component has a version.""" component = FastMCPComponent(name="test", version="v1.0.0", tags={"tag1"}) result = component.get_meta() assert result is not None assert result["fastmcp"]["version"] == "v1.0.0" assert result["fastmcp"]["tags"] == ["tag1"] def test_get_meta_excludes_version_when_none(self): """Test that get_meta excludes version when component has no version.""" component = FastMCPComponent(name="test", tags={"tag1"}) result = component.get_meta() assert result is not None assert "version" not in result["fastmcp"] assert result["fastmcp"]["tags"] == ["tag1"] def test_equality_same_components(self): """Test that identical components are equal.""" comp1 = FastMCPComponent(name="test", description="desc") comp2 = FastMCPComponent(name="test", description="desc") assert comp1 == comp2 def test_equality_different_components(self): """Test that different components are not equal.""" comp1 = FastMCPComponent(name="test1") comp2 = FastMCPComponent(name="test2") assert comp1 != comp2 def test_equality_different_types(self, basic_component): """Test that component is not equal to other types.""" assert basic_component != "not a component" assert basic_component != 123 assert basic_component is not None def test_repr(self, basic_component): """Test string representation of component.""" repr_str = repr(basic_component) assert "FastMCPComponent" in repr_str assert "name='test_component'" in repr_str assert "title='Test Component'" in repr_str assert "description='A test component'" in repr_str def test_copy_method(self, basic_component): """Test copy method creates an independent copy.""" copy = basic_component.copy() assert copy == basic_component assert copy is not basic_component # Modify copy and ensure original is unchanged copy.name = "modified" assert basic_component.name == "test_component" def test_tags_deduplication(self): """Test that tags are deduplicated when passed as a sequence.""" component = FastMCPComponent( name="test", tags=["tag1", "tag2", "tag1", "tag2"], # type: ignore[arg-type] # ty:ignore[invalid-argument-type] ) assert component.tags == {"tag1", "tag2"} def test_validation_error_for_invalid_data(self): """Test that validation errors are raised for invalid data.""" with pytest.raises(ValidationError): FastMCPComponent() # type: ignore[call-arg] # ty:ignore[missing-argument] def test_extra_fields_forbidden(self): """Test that extra fields are not allowed.""" with pytest.raises(ValidationError) as exc_info: FastMCPComponent(name="test", unknown_field="value") # type: ignore[call-arg] # Intentionally passing invalid field for test # ty:ignore[unknown-argument] assert "Extra inputs are not permitted" in str(exc_info.value) class TestKeyPrefix: """Tests for KEY_PREFIX and make_key functionality.""" def test_base_class_has_empty_prefix(self): """Test that FastMCPComponent has empty KEY_PREFIX.""" assert FastMCPComponent.KEY_PREFIX == "" def test_make_key_without_prefix(self): """Test make_key returns just identifier when KEY_PREFIX is empty.""" assert FastMCPComponent.make_key("my_name") == "my_name" def test_tool_has_tool_prefix(self): """Test that Tool has 'tool' KEY_PREFIX.""" assert Tool.KEY_PREFIX == "tool" assert Tool.make_key("my_tool") == "tool:my_tool" def test_resource_has_resource_prefix(self): """Test that Resource has 'resource' KEY_PREFIX.""" assert Resource.KEY_PREFIX == "resource" assert Resource.make_key("file://test.txt") == "resource:file://test.txt" def test_template_has_template_prefix(self): """Test that ResourceTemplate has 'template' KEY_PREFIX.""" assert ResourceTemplate.KEY_PREFIX == "template" assert ResourceTemplate.make_key("data://{id}") == "template:data://{id}" def test_prompt_has_prompt_prefix(self): """Test that Prompt has 'prompt' KEY_PREFIX.""" assert Prompt.KEY_PREFIX == "prompt" assert Prompt.make_key("my_prompt") == "prompt:my_prompt" def test_tool_key_property(self): """Test that Tool.key returns prefixed key with version sentinel.""" tool = Tool(name="greet", description="A greeting tool", parameters={}) assert tool.key == "tool:greet@" def test_prompt_key_property(self): """Test that Prompt.key returns prefixed key with version sentinel.""" prompt = Prompt(name="analyze", description="An analysis prompt") assert prompt.key == "prompt:analyze@" def test_warning_for_missing_key_prefix(self): """Test that subclassing without KEY_PREFIX emits a warning.""" with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") class NoPrefix(FastMCPComponent): pass key_prefix_warnings = [ x for x in w if "does not define KEY_PREFIX" in str(x.message) ] assert len(key_prefix_warnings) == 1 def test_no_warning_when_key_prefix_defined(self): """Test that subclassing with KEY_PREFIX does not emit a warning.""" with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") class WithPrefix(FastMCPComponent): KEY_PREFIX = "custom" key_prefix_warnings = [ x for x in w if "does not define KEY_PREFIX" in str(x.message) ] assert len(key_prefix_warnings) == 0 assert WithPrefix.make_key("test") == "custom:test" class TestGetFastMCPMetadata: """Tests for get_fastmcp_metadata helper.""" def test_returns_fastmcp_namespace_when_dict(self): meta = {"fastmcp": {"tags": ["a"]}, "_fastmcp": {"tags": ["b"]}} assert get_fastmcp_metadata(meta) == {"tags": ["a"]} def test_falls_back_to_legacy_namespace_when_dict(self): meta = {"fastmcp": "invalid", "_fastmcp": {"tags": ["legacy"]}} assert get_fastmcp_metadata(meta) == {"tags": ["legacy"]} def test_ignores_non_dict_metadata(self): assert get_fastmcp_metadata({"fastmcp": "invalid"}) == {} assert get_fastmcp_metadata({"fastmcp": ["invalid"]}) == {} assert get_fastmcp_metadata({"_fastmcp": "invalid"}) == {} class TestComponentEnableDisable: """Tests for the enable/disable methods raising NotImplementedError.""" def test_enable_raises_not_implemented_error(self): """Test that enable() raises NotImplementedError with migration guidance.""" component = FastMCPComponent(name="test") with pytest.raises(NotImplementedError) as exc_info: component.enable() assert "server.enable" in str(exc_info.value) assert "test" in str(exc_info.value) def test_disable_raises_not_implemented_error(self): """Test that disable() raises NotImplementedError with migration guidance.""" component = FastMCPComponent(name="test") with pytest.raises(NotImplementedError) as exc_info: component.disable() assert "server.disable" in str(exc_info.value) assert "test" in str(exc_info.value) def test_tool_enable_raises_not_implemented(self): """Test that Tool.enable() raises NotImplementedError.""" tool = Tool(name="my_tool", description="A tool", parameters={}) with pytest.raises(NotImplementedError) as exc_info: tool.enable() assert "tool:my_tool@" in str(exc_info.value) def test_tool_disable_raises_not_implemented(self): """Test that Tool.disable() raises NotImplementedError.""" tool = Tool(name="my_tool", description="A tool", parameters={}) with pytest.raises(NotImplementedError) as exc_info: tool.disable() assert "tool:my_tool@" in str(exc_info.value) def test_prompt_enable_raises_not_implemented(self): """Test that Prompt.enable() raises NotImplementedError.""" prompt = Prompt(name="my_prompt", description="A prompt") with pytest.raises(NotImplementedError) as exc_info: prompt.enable() assert "prompt:my_prompt@" in str(exc_info.value) class TestFastMCPMeta: """Tests for the FastMCPMeta TypedDict.""" def test_fastmcp_meta_structure(self): """Test that FastMCPMeta has the expected structure.""" meta: FastMCPMeta = {"tags": ["tag1", "tag2"]} assert meta["tags"] == ["tag1", "tag2"] def test_fastmcp_meta_with_version(self): """Test that FastMCPMeta can include version.""" meta: FastMCPMeta = {"tags": ["tag1"], "version": "v1.0.0"} assert meta["tags"] == ["tag1"] assert meta["version"] == "v1.0.0" def test_fastmcp_meta_optional_fields(self): """Test that FastMCPMeta fields are optional.""" meta: FastMCPMeta = {} assert "tags" not in meta # Should be optional assert "version" not in meta # Should be optional class TestEdgeCasesAndIntegration: """Tests for edge cases and integration scenarios.""" def test_empty_tags_conversion(self): """Test that empty tags are handled correctly.""" component = FastMCPComponent(name="test", tags=set()) assert component.tags == set() def test_tags_with_none_values(self): """Test tags behavior with various input types.""" # Test with None (through validator) component = FastMCPComponent(name="test") assert component.tags == set() def test_get_meta_returns_copy(self): """Test that get_meta returns a copy, not a reference to the original.""" component = FastMCPComponent(name="test", meta={"key": "value"}) meta = component.get_meta() assert meta is not None meta["key"] = "modified" assert component.meta is not None # get_meta returns a copy - mutating it doesn't affect the original assert component.meta["key"] == "value" def test_component_with_complex_meta(self): """Test component with nested meta structures.""" complex_meta = { "nested": {"level1": {"level2": "value"}}, "list": [1, 2, 3], "bool": True, } component = FastMCPComponent(name="test", meta=complex_meta) assert component.meta == complex_meta def test_model_copy_preserves_all_attributes(self): """Test that model_copy preserves all component attributes.""" component = FastMCPComponent( name="test", title="Title", description="Description", tags={"tag1", "tag2"}, meta={"key": "value"}, ) new_component = component.model_copy() assert new_component.name == component.name assert new_component.title == component.title assert new_component.description == component.description assert new_component.tags == component.tags assert new_component.meta == component.meta assert new_component.key == component.key def test_model_copy_with_update(self): """Test that model_copy works with update dict.""" component = FastMCPComponent( name="test", title="Original Title", description="Original Description", tags={"tag1"}, ) # Test with update (including name which affects .key) updated_component = component.model_copy( update={ "name": "new_name", "title": "New Title", "description": "New Description", }, ) assert updated_component.name == "new_name" # Updated assert updated_component.title == "New Title" # Updated assert updated_component.description == "New Description" # Updated assert updated_component.tags == {"tag1"} # Not in update, unchanged assert ( updated_component.key == "new_name@" ) # .key is computed from name with @ sentinel # Original should be unchanged assert component.name == "test" assert component.title == "Original Title" assert component.description == "Original Description" assert component.key == "test@" # Uses name as key with @ sentinel def test_model_copy_deep_parameter(self): """Test that model_copy respects the deep parameter.""" nested_dict = {"nested": {"value": 1}} component = FastMCPComponent(name="test", meta=nested_dict) # Shallow copy (default) shallow_copy = component.model_copy() assert shallow_copy.meta is not None assert component.meta is not None shallow_copy.meta["nested"]["value"] = 2 assert component.meta["nested"]["value"] == 2 # Original affected # Deep copy component.meta["nested"]["value"] = 1 # Reset deep_copy = component.model_copy(deep=True) assert deep_copy.meta is not None deep_copy.meta["nested"]["value"] = 3 assert component.meta["nested"]["value"] == 1 # Original unaffected