diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py index 107339ca8..d34f7e1c7 100644 --- a/src/fastmcp/server/providers/base.py +++ b/src/fastmcp/server/providers/base.py @@ -511,7 +511,7 @@ class Provider: *, names: set[str] | None = None, keys: set[str] | None = None, - version: str | None = None, + version: VersionSpec | None = None, tags: set[str] | None = None, components: list[Literal["tool", "resource", "template", "prompt"]] | None = None, @@ -529,7 +529,8 @@ class Provider: Args: names: Component names or URIs to enable. keys: Component keys to enable (e.g., {"tool:my_tool@v1"}). - version: Component version to enable. + version: Component version spec to enable (e.g., VersionSpec(eq="v1") or + VersionSpec(gte="v2")). Unversioned components will not match. tags: Enable components with these tags. components: Component types to include (e.g., ["tool", "prompt"]). only: If True, ONLY enable matching components (allowlist mode). @@ -559,7 +560,7 @@ class Provider: *, names: set[str] | None = None, keys: set[str] | None = None, - version: str | None = None, + version: VersionSpec | None = None, tags: set[str] | None = None, components: list[Literal["tool", "resource", "template", "prompt"]] | None = None, @@ -573,7 +574,8 @@ class Provider: Args: names: Component names or URIs to disable. keys: Component keys to disable (e.g., {"tool:my_tool@v1"}). - version: Component version to disable. + version: Component version spec to disable (e.g., VersionSpec(eq="v1") or + VersionSpec(gte="v2")). Unversioned components will not match. tags: Disable components with these tags. components: Component types to include (e.g., ["tool", "prompt"]). diff --git a/src/fastmcp/server/transforms/enabled.py b/src/fastmcp/server/transforms/enabled.py index 26570d708..98614bc09 100644 --- a/src/fastmcp/server/transforms/enabled.py +++ b/src/fastmcp/server/transforms/enabled.py @@ -64,7 +64,7 @@ class Enabled(Transform): *, names: set[str] | None = None, keys: set[str] | None = None, - version: str | None = None, + version: VersionSpec | None = None, tags: frozenset[str] | None = None, components: frozenset[str] | None = None, match_all: bool = False, @@ -75,7 +75,8 @@ class Enabled(Transform): enabled: If True, mark matching as enabled; if False, mark as disabled. names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). - version: Component version to match. + version: Component version spec to match. Unversioned components (version=None) + will NOT match a version spec. tags: Tags to match (component must have at least one). components: Component types to match (e.g., frozenset({"tool", "prompt"})). match_all: If True, matches all components regardless of other criteria. @@ -160,7 +161,10 @@ class Enabled(Transform): return False # Check version if specified - if self.version is not None and component.version != self.version: + # Note: match_none=False means unversioned components don't match a version spec + if self.version is not None and not self.version.matches( + component.version, match_none=False + ): return False # Check tags if specified (component must have at least one matching tag) diff --git a/src/fastmcp/utilities/versions.py b/src/fastmcp/utilities/versions.py index 42c2c480b..fabd88111 100644 --- a/src/fastmcp/utilities/versions.py +++ b/src/fastmcp/utilities/versions.py @@ -42,18 +42,21 @@ class VersionSpec: lt: str | None = None eq: str | None = None - def matches(self, version: str | None) -> bool: + def matches(self, version: str | None, *, match_none: bool = True) -> bool: """Check if a version matches this spec. Args: version: The version to check, or None for unversioned. + match_none: Whether unversioned (None) components match. Defaults to True + for backward compatibility with retrieval operations. Set to False + when filtering (e.g., enable/disable) to exclude unversioned components + from version-specific rules. Returns: True if the version matches the spec. """ if version is None: - # Unversioned components always match - return True + return match_none if self.eq is not None: return version == self.eq diff --git a/tests/server/transforms/test_enabled.py b/tests/server/transforms/test_enabled.py index 88df14d93..6dca8e56b 100644 --- a/tests/server/transforms/test_enabled.py +++ b/tests/server/transforms/test_enabled.py @@ -4,6 +4,7 @@ import pytest from fastmcp.server.transforms.enabled import Enabled, is_enabled from fastmcp.tools.tool import Tool +from fastmcp.utilities.versions import VersionSpec class TestMatching: @@ -27,10 +28,41 @@ class TestMatching: def test_match_by_version(self): """Matches component by version.""" - t = Enabled(False, version="v1") + t = Enabled(False, version=VersionSpec(eq="v1")) assert t._matches(Tool(name="foo", version="v1", parameters={})) is True assert t._matches(Tool(name="foo", version="v2", parameters={})) is False + def test_match_by_version_spec_exact(self): + """VersionSpec(eq="v1") matches v1 only.""" + t = Enabled(False, version=VersionSpec(eq="v1")) + assert t._matches(Tool(name="foo", version="v1", parameters={})) is True + assert t._matches(Tool(name="foo", version="v2", parameters={})) is False + assert t._matches(Tool(name="foo", version="v0", parameters={})) is False + + def test_match_by_version_spec_gte(self): + """VersionSpec(gte="v2") matches v2, v3, but not v1.""" + t = Enabled(False, version=VersionSpec(gte="v2")) + assert t._matches(Tool(name="foo", version="v1", parameters={})) is False + assert t._matches(Tool(name="foo", version="v2", parameters={})) is True + assert t._matches(Tool(name="foo", version="v3", parameters={})) is True + + def test_match_by_version_spec_range(self): + """VersionSpec(gte="v1", lt="v3") matches v1, v2, but not v3.""" + t = Enabled(False, version=VersionSpec(gte="v1", lt="v3")) + assert t._matches(Tool(name="foo", version="v0", parameters={})) is False + assert t._matches(Tool(name="foo", version="v1", parameters={})) is True + assert t._matches(Tool(name="foo", version="v2", parameters={})) is True + assert t._matches(Tool(name="foo", version="v3", parameters={})) is False + assert t._matches(Tool(name="foo", version="v4", parameters={})) is False + + def test_unversioned_does_not_match_version_spec(self): + """Unversioned components (version=None) don't match a VersionSpec.""" + t = Enabled(False, version=VersionSpec(eq="v1")) + assert t._matches(Tool(name="foo", parameters={})) is False + + t2 = Enabled(False, version=VersionSpec(gte="v1")) + assert t2._matches(Tool(name="foo", parameters={})) is False + def test_match_by_tag(self): """Matches if component has any of the specified tags.""" t = Enabled(False, tags=frozenset({"internal", "deprecated"})) @@ -48,7 +80,7 @@ class TestMatching: t = Enabled( False, names={"foo"}, - version="v1", + version=VersionSpec(eq="v1"), tags=frozenset({"internal"}), ) # All match