Merge pull request #2914 from jlowin/jlowin/version-spec-in-enabled

This commit is contained in:
Jeremiah Lowin 2026-01-18 16:18:13 -05:00 committed by GitHub
commit 837038677d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 53 additions and 12 deletions

View file

@ -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"]).

View file

@ -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)

View file

@ -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

View file

@ -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