"""Tests for component versioning functionality.""" # ruff: noqa: F811 # Intentional function redefinition for version testing from __future__ import annotations from mcp.types import TextContent from fastmcp import FastMCP from fastmcp.utilities.versions import ( VersionKey, VersionSpec, compare_versions, is_version_greater, ) class TestVersionKey: """Tests for VersionKey comparison class.""" def test_none_sorts_lowest(self): """None (unversioned) should sort lower than any version.""" assert VersionKey(None) < VersionKey("1.0") assert VersionKey(None) < VersionKey("0.1") assert VersionKey(None) < VersionKey("anything") def test_none_equals_none(self): """Two None versions should be equal.""" assert VersionKey(None) == VersionKey(None) assert not (VersionKey(None) < VersionKey(None)) assert not (VersionKey(None) > VersionKey(None)) def test_pep440_versions_compared_semantically(self): """Valid PEP 440 versions should compare semantically.""" assert VersionKey("1.0") < VersionKey("2.0") assert VersionKey("1.0") < VersionKey("1.1") assert VersionKey("1.9") < VersionKey("1.10") # Semantic, not string assert VersionKey("2") < VersionKey("10") # Semantic, not string def test_v_prefix_stripped(self): """Versions with 'v' prefix should be handled correctly.""" assert VersionKey("v1.0") == VersionKey("1.0") assert VersionKey("v2.0") > VersionKey("v1.0") def test_string_fallback_for_invalid_versions(self): """Invalid PEP 440 versions should fall back to string comparison.""" # Dates are not valid PEP 440 assert VersionKey("2024-01-01") < VersionKey("2025-01-01") # String comparison (lexicographic) assert VersionKey("alpha") < VersionKey("beta") def test_pep440_sorts_before_strings(self): """PEP 440 versions sort before invalid string versions.""" # "1.0" is valid PEP 440, "not-semver" is not assert VersionKey("1.0") < VersionKey("not-semver") assert VersionKey("999.0") < VersionKey("aaa") # PEP 440 < string def test_repr(self): """Test string representation.""" assert repr(VersionKey("1.0")) == "VersionKey('1.0')" assert repr(VersionKey(None)) == "VersionKey(None)" class TestVersionFunctions: """Tests for version comparison functions.""" def test_compare_versions(self): """Test compare_versions function.""" assert compare_versions("1.0", "2.0") == -1 assert compare_versions("2.0", "1.0") == 1 assert compare_versions("1.0", "1.0") == 0 assert compare_versions(None, "1.0") == -1 assert compare_versions("1.0", None) == 1 assert compare_versions(None, None) == 0 def test_is_version_greater(self): """Test is_version_greater function.""" assert is_version_greater("2.0", "1.0") assert not is_version_greater("1.0", "2.0") assert not is_version_greater("1.0", "1.0") assert is_version_greater("1.0", None) assert not is_version_greater(None, "1.0") class TestComponentVersioning: """Tests for versioning in FastMCP components.""" async def test_tool_with_version(self): """Tool version should be reflected in key.""" mcp = FastMCP() @mcp.tool(version="2.0") def my_tool(x: int) -> int: return x * 2 tools = await mcp.list_tools() assert len(tools) == 1 assert tools[0].name == "my_tool" assert tools[0].version == "2.0" assert tools[0].key == "tool:my_tool@2.0" async def test_tool_without_version(self): """Tool without version should have @ sentinel in key but empty version.""" mcp = FastMCP() @mcp.tool def my_tool(x: int) -> int: return x * 2 tools = await mcp.list_tools() assert len(tools) == 1 assert tools[0].version is None # Keys always have @ sentinel for unambiguous parsing assert tools[0].key == "tool:my_tool@" async def test_tool_version_as_int(self): """Tool version as int should be coerced to string.""" mcp = FastMCP() @mcp.tool(version=2) def my_tool(x: int) -> int: return x * 2 tools = await mcp.list_tools() assert len(tools) == 1 assert tools[0].version == "2" assert tools[0].key == "tool:my_tool@2" async def test_tool_version_zero_is_truthy(self): """Version 0 should become "0" (truthy string), not empty.""" mcp = FastMCP() @mcp.tool(version=0) def my_tool(x: int) -> int: return x * 2 tools = await mcp.list_tools() assert len(tools) == 1 assert tools[0].version == "0" assert tools[0].key == "tool:my_tool@0" # Not "tool:my_tool@" async def test_multiple_tool_versions_all_returned(self): """list_tools returns all versions; get_tool returns highest.""" mcp = FastMCP() @mcp.tool(version="1.0") def add(x: int, y: int) -> int: return x + y @mcp.tool(version="2.0") def add(x: int, y: int, z: int = 0) -> int: return x + y + z # list_tools returns all versions tools = await mcp.list_tools() assert len(tools) == 2 versions = {t.version for t in tools} assert versions == {"1.0", "2.0"} # get_tool returns highest version tool = await mcp.get_tool("add") assert tool is not None assert tool.version == "2.0" async def test_call_tool_invokes_highest_version(self): """Calling a tool by name should invoke the highest version.""" mcp = FastMCP() @mcp.tool(version="1.0") def add(x: int, y: int) -> int: return x + y @mcp.tool(version="2.0") def add(x: int, y: int) -> int: return (x + y) * 10 # Different behavior to distinguish result = await mcp.call_tool("add", {"x": 1, "y": 2}) # Should invoke v2.0 which multiplies by 10 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "30" async def test_mixing_versioned_and_unversioned_rejected(self): """Cannot mix versioned and unversioned tools with the same name.""" import pytest mcp = FastMCP() @mcp.tool def my_tool() -> str: return "unversioned" # Adding versioned tool when unversioned exists should fail with pytest.raises(ValueError, match="versioned.*unversioned"): @mcp.tool(version="1.0") def my_tool() -> str: return "v1.0" async def test_mixing_unversioned_after_versioned_rejected(self): """Cannot add unversioned tool when versioned exists.""" import pytest mcp = FastMCP() @mcp.tool(version="1.0") def my_tool() -> str: return "v1.0" # Adding unversioned tool when versioned exists should fail with pytest.raises(ValueError, match="unversioned.*versioned"): @mcp.tool def my_tool() -> str: return "unversioned" async def test_resource_with_version(self): """Resource version should work like tool version.""" mcp = FastMCP() @mcp.resource("file:///config", version="1.0") def config_v1() -> str: return "config v1" @mcp.resource("file:///config", version="2.0") def config_v2() -> str: return "config v2" # list_resources returns all versions resources = await mcp.list_resources() assert len(resources) == 2 versions = {r.version for r in resources} assert versions == {"1.0", "2.0"} # get_resource returns highest version resource = await mcp.get_resource("file:///config") assert resource is not None assert resource.version == "2.0" async def test_prompt_with_version(self): """Prompt version should work like tool version.""" mcp = FastMCP() @mcp.prompt(version="1.0") def greet(name: str) -> str: return f"Hello, {name}!" @mcp.prompt(version="2.0") def greet(name: str) -> str: return f"Greetings, {name}!" # list_prompts returns all versions prompts = await mcp.list_prompts() assert len(prompts) == 2 versions = {p.version for p in prompts} assert versions == {"1.0", "2.0"} # get_prompt returns highest version prompt = await mcp.get_prompt("greet") assert prompt is not None assert prompt.version == "2.0" class TestVersionSorting: """Tests for version sorting behavior.""" async def test_semantic_version_sorting(self): """Versions should sort semantically, not lexicographically.""" mcp = FastMCP() # Add versions out of order @mcp.tool(version="1") def count() -> int: return 1 @mcp.tool(version="10") def count() -> int: return 10 @mcp.tool(version="2") def count() -> int: return 2 # list_tools returns all versions tools = await mcp.list_tools() assert len(tools) == 3 versions = {t.version for t in tools} assert versions == {"1", "2", "10"} # get_tool returns highest (semantic: 10 > 2 > 1) tool = await mcp.get_tool("count") assert tool is not None assert tool.version == "10" # call_tool uses highest version result = await mcp.call_tool("count", {}) assert isinstance(result.content[0], TextContent) assert result.content[0].text == "10" async def test_semver_sorting(self): """Full semver versions should sort correctly.""" mcp = FastMCP() @mcp.tool(version="1.2.3") def info() -> str: return "1.2.3" @mcp.tool(version="1.2.10") def info() -> str: return "1.2.10" @mcp.tool(version="1.10.1") def info() -> str: return "1.10.1" # list_tools returns all versions tools = await mcp.list_tools() assert len(tools) == 3 versions = {t.version for t in tools} assert versions == {"1.2.3", "1.2.10", "1.10.1"} # get_tool returns highest: 1.10.1 > 1.2.10 > 1.2.3 (semantic) tool = await mcp.get_tool("info") assert tool is not None assert tool.version == "1.10.1" async def test_v_prefix_normalized(self): """Versions with 'v' prefix should compare correctly.""" mcp = FastMCP() @mcp.tool(version="v1.0") def calc() -> int: return 1 @mcp.tool(version="v2.0") def calc() -> int: return 2 # list_tools returns all versions tools = await mcp.list_tools() assert len(tools) == 2 versions = {t.version for t in tools} assert versions == {"v1.0", "v2.0"} # get_tool returns highest tool = await mcp.get_tool("calc") assert tool is not None assert tool.version == "v2.0" class TestMountedServerVersioning: """Tests for versioning in mounted servers (FastMCPProvider).""" async def test_mounted_tool_preserves_version(self): """Mounted tools should preserve their version info.""" child = FastMCP("Child") @child.tool(version="2.0") def add(x: int, y: int) -> int: return x + y parent = FastMCP("Parent") parent.mount(child, "child") tools = await parent.list_tools() assert len(tools) == 1 assert tools[0].name == "child_add" assert tools[0].version == "2.0" async def test_mounted_resource_preserves_version(self): """Mounted resources should preserve their version info.""" child = FastMCP("Child") @child.resource("file:///config", version="1.5") def config() -> str: return "config data" parent = FastMCP("Parent") parent.mount(child, "child") resources = await parent.list_resources() assert len(resources) == 1 assert resources[0].version == "1.5" async def test_mounted_prompt_preserves_version(self): """Mounted prompts should preserve their version info.""" child = FastMCP("Child") @child.prompt(version="3.0") def greet(name: str) -> str: return f"Hello, {name}!" parent = FastMCP("Parent") parent.mount(child, "child") prompts = await parent.list_prompts() assert len(prompts) == 1 assert prompts[0].name == "child_greet" assert prompts[0].version == "3.0" async def test_mounted_get_tool_with_version(self): """Should be able to get specific version from mounted server.""" child = FastMCP("Child") @child.tool(version="1.0") def calc() -> int: return 1 @child.tool(version="2.0") def calc() -> int: return 2 parent = FastMCP("Parent") parent.mount(child, "child") # Get highest version (default) tool = await parent.get_tool("child_calc") assert tool is not None assert tool.version == "2.0" # Get specific version tool_v1 = await parent.get_tool("child_calc", VersionSpec(eq="1.0")) assert tool_v1 is not None assert tool_v1.version == "1.0" async def test_mounted_multiple_versions_all_returned(self): """Mounted server with multiple versions should show all versions.""" child = FastMCP("Child") @child.tool(version="1.0") def my_tool() -> str: return "v1" @child.tool(version="3.0") def my_tool() -> str: return "v3" @child.tool(version="2.0") def my_tool() -> str: return "v2" parent = FastMCP("Parent") parent.mount(child, "child") # list_tools returns all versions tools = await parent.list_tools() assert len(tools) == 3 versions = {t.version for t in tools} assert versions == {"1.0", "2.0", "3.0"} # get_tool returns highest tool = await parent.get_tool("child_my_tool") assert tool is not None assert tool.version == "3.0" async def test_mounted_call_tool_uses_highest_version(self): """Calling mounted tool should use highest version.""" child = FastMCP("Child") @child.tool(version="1.0") def double(x: int) -> int: return x * 2 @child.tool(version="2.0") def double(x: int) -> int: return x * 2 + 100 # Different behavior parent = FastMCP("Parent") parent.mount(child, "child") result = await parent.call_tool("child_double", {"x": 5}) # Should use v2.0 which adds 100 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "110" async def test_mounted_tool_wrapper_executes_correct_version(self): """Calling a specific versioned tool wrapper should execute that version.""" child = FastMCP("Child") @child.tool(version="1.0") def calc(x: int) -> int: return x * 10 # v1.0 multiplies by 10 @child.tool(version="2.0") def calc(x: int) -> int: return x * 100 # v2.0 multiplies by 100 parent = FastMCP("Parent") parent.mount(child, "child") # Get the v1.0 wrapper specifically tools = await parent.list_tools() v1_tool = next( t for t in tools if t.name == "child_calc" and t.version == "1.0" ) # Calling the v1.0 wrapper should execute v1.0's logic result = await v1_tool.run({"x": 5}) assert result.content[0].text == "50" # 5 * 10, not 5 * 100 async def test_mounted_resource_wrapper_reads_correct_version(self): """Reading a specific versioned resource should read that version.""" from fastmcp.utilities.versions import VersionSpec child = FastMCP("Child") @child.resource("data:///config", version="1.0") def config_v1() -> str: return "config-v1-content" @child.resource("data:///config", version="2.0") def config_v2() -> str: return "config-v2-content" parent = FastMCP("Parent") parent.mount(child, "child") # Reading with version=1.0 should read v1.0's content result = await parent.read_resource( "data://child//config", version=VersionSpec(eq="1.0") ) assert result.contents[0].content == "config-v1-content" # Reading with version=2.0 should read v2.0's content result = await parent.read_resource( "data://child//config", version=VersionSpec(eq="2.0") ) assert result.contents[0].content == "config-v2-content" async def test_mounted_prompt_wrapper_renders_correct_version(self): """Rendering a specific versioned prompt should render that version.""" from fastmcp.utilities.versions import VersionSpec child = FastMCP("Child") @child.prompt(version="1.0") def greeting(name: str) -> str: return f"Hello, {name}!" # v1.0 says Hello @child.prompt(version="2.0") def greeting(name: str) -> str: return f"Greetings, {name}!" # v2.0 says Greetings parent = FastMCP("Parent") parent.mount(child, "child") # Rendering with version=1.0 should render v1.0's content result = await parent.render_prompt( "child_greeting", {"name": "World"}, version=VersionSpec(eq="1.0") ) content = result.messages[0].content assert isinstance(content, TextContent) and "Hello, World!" in content.text # Rendering with version=2.0 should render v2.0's content result = await parent.render_prompt( "child_greeting", {"name": "World"}, version=VersionSpec(eq="2.0") ) content = result.messages[0].content assert isinstance(content, TextContent) and "Greetings, World!" in content.text async def test_deeply_nested_version_forwarding(self): """Verify version is correctly forwarded through multiple mount levels.""" level3 = FastMCP("Level3") @level3.tool(version="1.0") def calc(x: int) -> int: return x * 10 # v1.0 multiplies by 10 @level3.tool(version="2.0") def calc(x: int) -> int: return x * 100 # v2.0 multiplies by 100 level2 = FastMCP("Level2") level2.mount(level3, "l3") level1 = FastMCP("Level1") level1.mount(level2, "l2") # All versions should be visible through two levels of mounting tools = await level1.list_tools() calc_tools = [t for t in tools if "calc" in t.name] assert len(calc_tools) == 2 versions = {t.version for t in calc_tools} assert versions == {"1.0", "2.0"} # Get v1.0 wrapper through two levels of mounting v1_tool = next(t for t in tools if "calc" in t.name and t.version == "1.0") # Should execute v1.0 logic, not v2.0 result = await v1_tool.run({"x": 5}) assert isinstance(result.content[0], TextContent) assert result.content[0].text == "50" # 5 * 10, not 5 * 100 class TestVersionFilter: """Tests for VersionFilter transform.""" async def test_version_lt_filters_high_versions(self): """VersionFilter(version_lt='3.0') hides v3+, shows v1 and v2.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.tool(version="1.0") def calc() -> int: return 1 @mcp.tool(version="2.0") def calc() -> int: return 2 @mcp.tool(version="3.0") def calc() -> int: return 3 # Without filter, list_tools returns all versions tools = await mcp.list_tools() versions = {t.version for t in tools} assert versions == {"1.0", "2.0", "3.0"} # With filter, only v1 and v2 are visible mcp.add_transform(VersionFilter(version_lt="3.0")) tools = await mcp.list_tools() versions = {t.version for t in tools} assert versions == {"1.0", "2.0"} # get_tool returns highest matching version tool = await mcp.get_tool("calc") assert tool is not None assert tool.version == "2.0" async def test_version_gte_filters_low_versions(self): """VersionFilter(version_gte='2.0') hides v1, shows v2 and v3.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.tool(version="1.0") def add(x: int) -> int: return x + 1 @mcp.tool(version="2.0") def add(x: int) -> int: return x + 2 @mcp.tool(version="3.0") def add(x: int) -> int: return x + 3 mcp.add_transform(VersionFilter(version_gte="2.0")) # list_tools shows all matching versions (v2 and v3) tools = await mcp.list_tools() versions = {t.version for t in tools} assert versions == {"2.0", "3.0"} # get_tool returns highest matching version tool = await mcp.get_tool("add") assert tool is not None assert tool.version == "3.0" # Can request specific versions in range tool_v2 = await mcp.get_tool("add", VersionSpec(eq="2.0")) assert tool_v2 is not None assert tool_v2.version == "2.0" # Cannot request version outside range - returns None assert await mcp.get_tool("add", VersionSpec(eq="1.0")) is None async def test_version_range(self): """VersionFilter(version_gte='2.0', version_lt='3.0') shows only v2.x.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.tool(version="1.0") def calc() -> int: return 1 @mcp.tool(version="2.0") def calc() -> int: return 2 @mcp.tool(version="2.5") def calc() -> int: return 25 @mcp.tool(version="3.0") def calc() -> int: return 3 mcp.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0")) # list_tools shows all versions in range tools = await mcp.list_tools() versions = {t.version for t in tools} assert versions == {"2.0", "2.5"} # get_tool returns highest in range tool = await mcp.get_tool("calc") assert tool is not None assert tool.version == "2.5" # Can request specific versions in range tool_v2 = await mcp.get_tool("calc", VersionSpec(eq="2.0")) assert tool_v2 is not None assert tool_v2.version == "2.0" # Versions outside range are not accessible - return None assert await mcp.get_tool("calc", VersionSpec(eq="1.0")) is None assert await mcp.get_tool("calc", VersionSpec(eq="3.0")) is None async def test_unversioned_always_passes(self): """Unversioned components pass through any filter.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.tool def unversioned_tool() -> str: return "unversioned" @mcp.tool(version="5.0") def versioned_tool() -> str: return "v5" # Filter that would exclude v5.0 mcp.add_transform(VersionFilter(version_lt="3.0")) tools = await mcp.list_tools() names = [t.name for t in tools] assert "unversioned_tool" in names assert "versioned_tool" not in names async def test_date_versions(self): """Works with date-based versions like '2025-01-15'.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.tool(version="2025-01-01") def report() -> str: return "jan" @mcp.tool(version="2025-06-01") def report() -> str: return "jun" @mcp.tool(version="2025-12-01") def report() -> str: return "dec" # Q1 API: before April mcp.add_transform(VersionFilter(version_lt="2025-04-01")) tools = await mcp.list_tools() assert len(tools) == 1 assert tools[0].version == "2025-01-01" async def test_get_tool_respects_filter(self): """get_tool() returns None if highest version is filtered out.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.tool(version="5.0") def only_v5() -> str: return "v5" mcp.add_transform(VersionFilter(version_lt="3.0")) # Tool exists but is filtered out - returns None (use get_tool to apply transforms) assert await mcp.get_tool("only_v5") is None async def test_must_specify_at_least_one(self): """VersionFilter() with no args raises ValueError.""" import pytest from fastmcp.server.transforms import VersionFilter with pytest.raises(ValueError, match="At least one of"): VersionFilter() async def test_resources_filtered(self): """Resources are filtered by version.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.resource("file:///config", version="1.0") def config_v1() -> str: return "v1" @mcp.resource("file:///config", version="2.0") def config_v2() -> str: return "v2" mcp.add_transform(VersionFilter(version_lt="2.0")) resources = await mcp.list_resources() assert len(resources) == 1 assert resources[0].version == "1.0" async def test_prompts_filtered(self): """Prompts are filtered by version.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.prompt(version="1.0") def greet(name: str) -> str: return f"Hi {name}" @mcp.prompt(version="2.0") def greet(name: str) -> str: return f"Hello {name}" mcp.add_transform(VersionFilter(version_lt="2.0")) prompts = await mcp.list_prompts() assert len(prompts) == 1 assert prompts[0].version == "1.0" async def test_repr(self): """Test VersionFilter string representation.""" from fastmcp.server.transforms import VersionFilter f1 = VersionFilter(version_lt="3.0") assert repr(f1) == "VersionFilter(version_lt='3.0')" f2 = VersionFilter(version_gte="2.0", version_lt="3.0") assert repr(f2) == "VersionFilter(version_gte='2.0', version_lt='3.0')" f3 = VersionFilter(version_gte="1.0") assert repr(f3) == "VersionFilter(version_gte='1.0')" class TestVersionMixingValidation: """Tests for versioned/unversioned mixing prevention.""" async def test_resource_mixing_rejected(self): """Cannot mix versioned and unversioned resources with the same URI.""" import pytest mcp = FastMCP() @mcp.resource("file:///config", version="1.0") def config_v1() -> str: return "v1" with pytest.raises(ValueError, match="unversioned.*versioned"): @mcp.resource("file:///config") def config_unversioned() -> str: return "unversioned" async def test_prompt_mixing_rejected(self): """Cannot mix versioned and unversioned prompts with the same name.""" import pytest mcp = FastMCP() @mcp.prompt def greet(name: str) -> str: return f"Hello, {name}!" with pytest.raises(ValueError, match="versioned.*unversioned"): @mcp.prompt(version="1.0") def greet(name: str) -> str: return f"Hi, {name}!" async def test_multiple_versions_allowed(self): """Multiple versioned components with same name are allowed.""" mcp = FastMCP() @mcp.tool(version="1.0") def calc() -> int: return 1 @mcp.tool(version="2.0") def calc() -> int: return 2 @mcp.tool(version="3.0") def calc() -> int: return 3 # All versioned - list_tools returns all tools = await mcp.list_tools() assert len(tools) == 3 versions = {t.version for t in tools} assert versions == {"1.0", "2.0", "3.0"} # get_tool returns highest tool = await mcp.get_tool("calc") assert tool is not None assert tool.version == "3.0" class TestMountedVersionFiltering: """Tests for version filtering with mounted servers (FastMCPProvider). Note: For mounted servers, list_* methods show what the child exposes (already deduplicated to highest version). get_* methods support range filtering via VersionSpec propagation to FastMCPProvider. """ async def test_mounted_get_tool_with_range_filter(self): """FastMCPProvider.get_tool applies range filtering from VersionSpec.""" from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.utilities.versions import VersionSpec child = FastMCP("Child") @child.tool(version="2.0") def calc() -> int: return 2 provider = FastMCPProvider(child) # Without range spec, should return the tool tool = await provider.get_tool("calc") assert tool is not None assert tool.version == "2.0" # With range spec that excludes v2.0, should return None tool = await provider.get_tool("calc", version=VersionSpec(lt="2.0")) assert tool is None # With range spec that includes v2.0, should return the tool tool = await provider.get_tool("calc", version=VersionSpec(gte="2.0")) assert tool is not None assert tool.version == "2.0" async def test_mounted_get_resource_with_range_filter(self): """FastMCPProvider.get_resource applies range filtering from VersionSpec.""" from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.utilities.versions import VersionSpec child = FastMCP("Child") @child.resource("file://data/", version="2.0") def data() -> str: return "data" provider = FastMCPProvider(child) # Without range spec, should return the resource resource = await provider.get_resource("file://data/") assert resource is not None assert resource.version == "2.0" # With range spec that excludes v2.0, should return None resource = await provider.get_resource( "file://data/", version=VersionSpec(lt="2.0") ) assert resource is None async def test_mounted_get_prompt_with_range_filter(self): """FastMCPProvider.get_prompt applies range filtering from VersionSpec.""" from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.utilities.versions import VersionSpec child = FastMCP("Child") @child.prompt(version="2.0") def greet(name: str) -> str: return f"Hello {name}" provider = FastMCPProvider(child) # Without range spec, should return the prompt prompt = await provider.get_prompt("greet") assert prompt is not None assert prompt.version == "2.0" # With range spec that excludes v2.0, should return None prompt = await provider.get_prompt("greet", version=VersionSpec(lt="2.0")) assert prompt is None async def test_mounted_unversioned_passes_version_filter(self): """Unversioned components in mounted servers pass through version filters.""" from fastmcp.server.transforms import VersionFilter child = FastMCP("Child") @child.tool def unversioned_tool() -> str: return "unversioned" parent = FastMCP("Parent") parent.mount(child, "child") parent.add_transform(VersionFilter(version_lt="3.0")) # Unversioned should pass through tools = await parent.list_tools() assert len(tools) == 1 assert tools[0].name == "child_unversioned_tool" assert tools[0].version is None async def test_version_filter_filters_out_high_mounted_version(self): """VersionFilter hides mounted components outside the range.""" from fastmcp.server.transforms import VersionFilter child = FastMCP("Child") @child.tool(version="5.0") def high_version_tool() -> int: return 5 parent = FastMCP("Parent") parent.mount(child, "child") parent.add_transform(VersionFilter(version_lt="3.0")) # v5.0 is outside the filter range, so it should be hidden tools = await parent.list_tools() assert len(tools) == 0 # get_tool should also return None (respects filter, applies transforms) assert await parent.get_tool("child_high_version_tool") is None class TestMountedRangeFiltering: """Tests for version range filtering with mounted servers.""" async def test_mounted_lower_version_selected_by_filter(self): """When parent has filter <2.0 and child has v1.0+v3.0, should get v1.0.""" from fastmcp.server.transforms import VersionFilter child = FastMCP("Child") @child.tool(version="1.0") def calc() -> int: return 1 @child.tool(version="3.0") def calc() -> int: return 3 parent = FastMCP("Parent") parent.mount(child, "child") parent.add_transform(VersionFilter(version_lt="2.0")) # Should return v1.0 (the highest version that matches <2.0) # Use get_tool to apply transforms tool = await parent.get_tool("child_calc") assert tool is not None assert tool.version == "1.0" async def test_explicit_version_honored_within_filter_range(self): """Explicit version="1.0" request should work within filter range.""" from fastmcp.server.transforms import VersionFilter child = FastMCP("Child") @child.tool(version="1.0") def calc() -> int: return 1 @child.tool(version="2.0") def calc() -> int: return 2 @child.tool(version="3.0") def calc() -> int: return 3 parent = FastMCP("Parent") parent.mount(child, "child") parent.add_transform(VersionFilter(version_gte="1.0", version_lt="3.0")) # Request specific version within range (use get_tool to apply transforms) tool = await parent.get_tool("child_calc", VersionSpec(eq="1.0")) assert tool is not None assert tool.version == "1.0" # Request version outside range should return None result = await parent.get_tool("child_calc", VersionSpec(eq="3.0")) assert result is None class TestUnversionedExemption: """Tests confirming unversioned components bypass version filters.""" async def test_unversioned_bypasses_version_filter(self): """Unversioned components pass through any VersionFilter - by design.""" from fastmcp.server.transforms import VersionFilter mcp = FastMCP() @mcp.tool def unversioned_tool() -> str: return "unversioned" @mcp.tool(version="5.0") def versioned_tool() -> str: return "v5" # Filter that would exclude v5.0 mcp.add_transform(VersionFilter(version_lt="3.0")) tools = await mcp.list_tools() names = [t.name for t in tools] # Unversioned passes through (exempt from filtering) assert "unversioned_tool" in names # Versioned is filtered out assert "versioned_tool" not in names async def test_unversioned_returned_for_exact_version_request(self): """Requesting exact version of unversioned tool returns the tool.""" mcp = FastMCP() @mcp.tool def my_tool() -> str: return "unversioned" # Even with explicit version request, unversioned tool is returned # (it's the only version that exists, and unversioned matches any spec) tool = await mcp.get_tool("my_tool", VersionSpec(eq="1.0")) assert tool is not None assert tool.version is None async def test_unversioned_matches_any_version_spec(self): """VersionSpec.matches(None) returns True for any spec.""" from fastmcp.utilities.versions import VersionSpec # Unversioned matches exact version specs assert VersionSpec(eq="1.0").matches(None) is True # Unversioned matches range specs assert VersionSpec(gte="1.0", lt="3.0").matches(None) is True # Unversioned matches open specs assert VersionSpec(lt="5.0").matches(None) is True assert VersionSpec(gte="1.0").matches(None) is True class TestVersionValidation: """Tests for version string validation.""" async def test_version_with_at_symbol_rejected(self): """Version strings containing '@' should be rejected.""" import pytest from pydantic import ValidationError mcp = FastMCP() with pytest.raises(ValidationError, match="cannot contain '@'"): @mcp.tool(version="1.0@beta") def my_tool() -> str: return "test" class TestVersionMetadata: """Tests for version metadata exposure in list operations.""" async def test_tool_versions_in_meta(self): """Each version has its own version in metadata.""" mcp = FastMCP() @mcp.tool(version="1.0") def add(x: int, y: int) -> int: # noqa: F811 return x + y @mcp.tool(version="2.0") def add(x: int, y: int) -> int: # noqa: F811 return x + y # list_tools returns all versions tools = await mcp.list_tools() assert len(tools) == 2 # Each version has its own version in metadata by_version = {t.version: t for t in tools} assert by_version["1.0"].get_meta()["fastmcp"]["version"] == "1.0" assert by_version["2.0"].get_meta()["fastmcp"]["version"] == "2.0" async def test_resource_versions_in_meta(self): """Each version has its own version in metadata.""" mcp = FastMCP() @mcp.resource("data://config", version="1.0") def config_v1() -> str: # noqa: F811 return "v1" @mcp.resource("data://config", version="2.0") def config_v2() -> str: # noqa: F811 return "v2" # list_resources returns all versions resources = await mcp.list_resources() assert len(resources) == 2 # Each version has its own version in metadata by_version = {r.version: r for r in resources} assert by_version["1.0"].get_meta()["fastmcp"]["version"] == "1.0" assert by_version["2.0"].get_meta()["fastmcp"]["version"] == "2.0" async def test_prompt_versions_in_meta(self): """Each version has its own version in metadata.""" mcp = FastMCP() @mcp.prompt(version="1.0") def greet() -> str: # noqa: F811 return "Hello v1" @mcp.prompt(version="2.0") def greet() -> str: # noqa: F811 return "Hello v2" # list_prompts returns all versions prompts = await mcp.list_prompts() assert len(prompts) == 2 # Each version has its own version in metadata by_version = {p.version: p for p in prompts} assert by_version["1.0"].get_meta()["fastmcp"]["version"] == "1.0" assert by_version["2.0"].get_meta()["fastmcp"]["version"] == "2.0" async def test_unversioned_no_versions_list(self): """Unversioned components should not have versions list in meta.""" mcp = FastMCP() @mcp.tool def simple() -> str: return "simple" tools = await mcp.list_tools() assert len(tools) == 1 tool = tools[0] meta = tool.get_meta() assert "versions" not in meta.get("fastmcp", {}) class TestVersionedCalls: """Tests for calling specific component versions.""" async def test_call_tool_with_version(self): """call_tool should use specified version.""" mcp = FastMCP() @mcp.tool(version="1.0") def calculate(x: int, y: int) -> int: # noqa: F811 return x + y @mcp.tool(version="2.0") def calculate(x: int, y: int) -> int: # noqa: F811 return x * y # Default: highest version (2.0, multiplication) result = await mcp.call_tool("calculate", {"x": 3, "y": 4}) assert result.structured_content is not None assert result.structured_content["result"] == 12 # Explicit v1.0 (addition) result = await mcp.call_tool( "calculate", {"x": 3, "y": 4}, version=VersionSpec(eq="1.0") ) assert result.structured_content is not None assert result.structured_content["result"] == 7 # Explicit v2.0 (multiplication) result = await mcp.call_tool( "calculate", {"x": 3, "y": 4}, version=VersionSpec(eq="2.0") ) assert result.structured_content is not None assert result.structured_content["result"] == 12 async def test_read_resource_with_version(self): """read_resource should use specified version.""" mcp = FastMCP() @mcp.resource("data://config", version="1.0") def config() -> str: # noqa: F811 return "config v1" @mcp.resource("data://config", version="2.0") def config() -> str: # noqa: F811 return "config v2" # Default: highest version result = await mcp.read_resource("data://config") assert result.contents[0].content == "config v2" # Explicit v1.0 result = await mcp.read_resource("data://config", version=VersionSpec(eq="1.0")) assert result.contents[0].content == "config v1" async def test_render_prompt_with_version(self): """render_prompt should use specified version.""" mcp = FastMCP() @mcp.prompt(version="1.0") def greet() -> str: # noqa: F811 return "Hello from v1" @mcp.prompt(version="2.0") def greet() -> str: # noqa: F811 return "Hello from v2" # Default: highest version result = await mcp.render_prompt("greet") content = result.messages[0].content assert isinstance(content, TextContent) and content.text == "Hello from v2" # Explicit v1.0 result = await mcp.render_prompt("greet", version=VersionSpec(eq="1.0")) content = result.messages[0].content assert isinstance(content, TextContent) and content.text == "Hello from v1" async def test_call_tool_invalid_version_not_found(self): """Calling with non-existent version should raise NotFoundError.""" import pytest from fastmcp.exceptions import NotFoundError mcp = FastMCP() @mcp.tool(version="1.0") def mytool() -> str: return "v1" with pytest.raises(NotFoundError): await mcp.call_tool("mytool", {}, version=VersionSpec(eq="999.0")) class TestClientVersionSelection: """Tests for client-side version selection via the version parameter. Version selection flows through request-level _meta, not arguments. """ import pytest @pytest.mark.parametrize( "version,expected", [ (None, 10), # Default: highest version (2.0) -> 5 * 2 ("1.0", 6), # v1.0 -> 5 + 1 ("2.0", 10), # v2.0 -> 5 * 2 ], ) async def test_call_tool_version_selection( self, version: str | None, expected: int ): """Client.call_tool routes to correct version via request meta.""" from fastmcp import Client mcp = FastMCP() @mcp.tool(version="1.0") def calc(x: int) -> int: # noqa: F811 return x + 1 @mcp.tool(version="2.0") def calc(x: int) -> int: # noqa: F811 return x * 2 async with Client(mcp) as client: result = await client.call_tool("calc", {"x": 5}, version=version) assert result.data == expected @pytest.mark.parametrize( "version,expected", [ (None, "Hello world from v2"), # Default: highest version ("1.0", "Hello world from v1"), ("2.0", "Hello world from v2"), ], ) async def test_get_prompt_version_selection( self, version: str | None, expected: str ): """Client.get_prompt routes to correct version via request meta.""" from fastmcp import Client mcp = FastMCP() @mcp.prompt(version="1.0") def greet(name: str) -> str: # noqa: F811 return f"Hello {name} from v1" @mcp.prompt(version="2.0") def greet(name: str) -> str: # noqa: F811 return f"Hello {name} from v2" async with Client(mcp) as client: result = await client.get_prompt( "greet", {"name": "world"}, version=version ) content = result.messages[0].content assert isinstance(content, TextContent) and content.text == expected @pytest.mark.parametrize( "version,expected", [ (None, "v2 data"), # Default: highest version ("1.0", "v1 data"), ("2.0", "v2 data"), ], ) async def test_read_resource_version_selection( self, version: str | None, expected: str ): """Client.read_resource routes to correct version via request meta.""" from fastmcp import Client mcp = FastMCP() @mcp.resource("data://info", version="1.0") def info_v1() -> str: # noqa: F811 return "v1 data" @mcp.resource("data://info", version="2.0") def info_v2() -> str: # noqa: F811 return "v2 data" async with Client(mcp) as client: result = await client.read_resource("data://info", version=version) assert result[0].text == expected