diff --git a/docs/servers/versioning.mdx b/docs/servers/versioning.mdx index 2d180a851..4c44a73bd 100644 --- a/docs/servers/versioning.mdx +++ b/docs/servers/versioning.mdx @@ -60,7 +60,7 @@ VersionFilter(version_gte="2.0", version_lt="3.0") ``` -**Unversioned components are exempt from version filtering.** A `VersionFilter` only affects versioned components - unversioned components always pass through regardless of the filter's constraints. This ensures that adding version filtering to a server with mixed versioned and unversioned tools doesn't accidentally hide the unversioned ones. To prevent confusion, FastMCP forbids mixing versioned and unversioned components with the same name. +**Unversioned components are exempt from version filtering by default.** Set `include_unversioned=False` to exclude them. Including them by default ensures that adding version filtering to a server with mixed versioned and unversioned components doesn't accidentally hide the unversioned ones. To prevent confusion, FastMCP forbids mixing versioned and unversioned components with the same name. ### Filtering Mounted Servers @@ -138,7 +138,7 @@ def calculate(x: int, y: int, z: int = 0) -> int: The error message explains the conflict: "Cannot add versioned tool 'calculate' (version='2.0'): an unversioned tool with this name already exists. Either version all components or none." -This restriction exists because unversioned components always pass through version filters. If you could mix versioned and unversioned components, you'd have no way to filter out the unversioned one using `VersionFilter`. By enforcing consistency at registration, FastMCP ensures version filtering behaves predictably. +This restriction helps keep version filtering behavior predictable. Resources and prompts follow the same pattern. diff --git a/src/fastmcp/server/transforms/version_filter.py b/src/fastmcp/server/transforms/version_filter.py index d49928cd0..1b1d0270c 100644 --- a/src/fastmcp/server/transforms/version_filter.py +++ b/src/fastmcp/server/transforms/version_filter.py @@ -24,9 +24,11 @@ if TYPE_CHECKING: class VersionFilter(Transform): """Filters components by version range. - When applied to a provider or server, only components within the version - range are visible. Within that filtered set, the highest version of each - component is exposed to clients (standard deduplication behavior). + When applied to a provider or server, components within the version range + are visible, and unversioned components are included by default. Within + that filtered set, the highest version of each component is exposed to + clients (standard deduplication behavior). Set + ``include_unversioned=False`` to exclude unversioned components. Parameters mirror comparison operators for clarity: @@ -41,6 +43,8 @@ class VersionFilter(Transform): Args: version_gte: Versions >= this value pass through. version_lt: Versions < this value pass through. + include_unversioned: Whether unversioned components (``version=None``) + should pass through the filter. Defaults to True. """ def __init__( @@ -48,6 +52,7 @@ class VersionFilter(Transform): *, version_gte: str | None = None, version_lt: str | None = None, + include_unversioned: bool = True, ) -> None: if version_gte is None and version_lt is None: raise ValueError( @@ -55,6 +60,7 @@ class VersionFilter(Transform): ) self.version_gte = version_gte self.version_lt = version_lt + self.include_unversioned = include_unversioned self._spec = VersionSpec(gte=version_gte, lt=version_lt) def __repr__(self) -> str: @@ -63,6 +69,8 @@ class VersionFilter(Transform): parts.append(f"version_gte={self.version_gte!r}") if self.version_lt: parts.append(f"version_lt={self.version_lt!r}") + if not self.include_unversioned: + parts.append("include_unversioned=False") return f"VersionFilter({', '.join(parts)})" # ------------------------------------------------------------------------- @@ -70,7 +78,11 @@ class VersionFilter(Transform): # ------------------------------------------------------------------------- async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - return [t for t in tools if self._spec.matches(t.version)] + return [ + t + for t in tools + if self._spec.matches(t.version, match_none=self.include_unversioned) + ] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None @@ -82,7 +94,11 @@ class VersionFilter(Transform): # ------------------------------------------------------------------------- async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: - return [r for r in resources if self._spec.matches(r.version)] + return [ + r + for r in resources + if self._spec.matches(r.version, match_none=self.include_unversioned) + ] async def get_resource( self, @@ -100,7 +116,11 @@ class VersionFilter(Transform): async def list_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: - return [t for t in templates if self._spec.matches(t.version)] + return [ + t + for t in templates + if self._spec.matches(t.version, match_none=self.include_unversioned) + ] async def get_resource_template( self, @@ -116,7 +136,11 @@ class VersionFilter(Transform): # ------------------------------------------------------------------------- async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: - return [p for p in prompts if self._spec.matches(p.version)] + return [ + p + for p in prompts + if self._spec.matches(p.version, match_none=self.include_unversioned) + ] async def get_prompt( self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None diff --git a/tests/server/versioning/test_filtering.py b/tests/server/versioning/test_filtering.py index 99e3221d9..c68cf163f 100644 --- a/tests/server/versioning/test_filtering.py +++ b/tests/server/versioning/test_filtering.py @@ -4,6 +4,7 @@ from __future__ import annotations from fastmcp import FastMCP +from fastmcp.server.transforms import VersionFilter from fastmcp.utilities.versions import ( VersionSpec, ) @@ -149,6 +150,28 @@ class TestVersionFilter: assert "unversioned_tool" in names assert "versioned_tool" not in names + async def test_include_unversioned_false_excludes_unversioned_tools(self): + """Setting include_unversioned=False hides unversioned tools.""" + + mcp = FastMCP() + + @mcp.tool + def unversioned_tool() -> str: + return "unversioned" + + @mcp.tool(version="2.0") + def included_versioned_tool() -> str: + return "v2" + + @mcp.tool(version="5.0") + def excluded_versioned_tool() -> str: + return "v5" + + mcp.add_transform(VersionFilter(version_lt="3.0", include_unversioned=False)) + + tools = await mcp.list_tools() + assert [tool.name for tool in tools] == ["included_versioned_tool"] + async def test_date_versions(self): """Works with date-based versions like '2025-01-15'.""" from fastmcp.server.transforms import VersionFilter @@ -219,6 +242,55 @@ class TestVersionFilter: assert len(resources) == 1 assert resources[0].version == "1.0" + async def test_include_unversioned_false_excludes_unversioned_resources(self): + """Setting include_unversioned=False hides unversioned resources.""" + + mcp = FastMCP() + + @mcp.resource("file:///unversioned") + def unversioned_resource() -> str: + return "unversioned" + + @mcp.resource("file:///included_versioned", version="1.0") + def included_versioned_resource() -> str: + return "v1" + + @mcp.resource("file:///excluded_versioned", version="5.0") + def excluded_versioned_resource() -> str: + return "v5" + + mcp.add_transform(VersionFilter(version_lt="2.0", include_unversioned=False)) + + resources = await mcp.list_resources() + assert [str(resource.uri) for resource in resources] == [ + "file:///included_versioned" + ] + + async def test_include_unversioned_false_excludes_unversioned_resource_templates( + self, + ): + """Setting include_unversioned=False hides unversioned resource templates.""" + mcp = FastMCP() + + @mcp.resource("resource://unversioned/{name}") + def unversioned_template(name: str) -> str: + return f"unversioned:{name}" + + @mcp.resource("resource://included_versioned/{name}", version="1.0") + def included_versioned_template(name: str) -> str: + return f"versioned:{name}" + + @mcp.resource("resource://excluded_versioned/{name}", version="5.0") + def excluded_versioned_template(name: str) -> str: + return f"excluded:{name}" + + mcp.add_transform(VersionFilter(version_lt="2.0", include_unversioned=False)) + + templates = await mcp.list_resource_templates() + assert [template.uri_template for template in templates] == [ + "resource://included_versioned/{name}" + ] + async def test_prompts_filtered(self): """Prompts are filtered by version.""" from fastmcp.server.transforms import VersionFilter @@ -239,6 +311,27 @@ class TestVersionFilter: assert len(prompts) == 1 assert prompts[0].version == "1.0" + async def test_include_unversioned_false_excludes_unversioned_prompts(self): + """Setting include_unversioned=False hides unversioned prompts.""" + mcp = FastMCP() + + @mcp.prompt + def unversioned_prompt(name: str) -> str: + return f"Unversioned: {name}" + + @mcp.prompt(version="1.0") + def included_versioned_prompt(name: str) -> str: + return f"Versioned: {name}" + + @mcp.prompt(version="5.0") + def excluded_versioned_prompt(name: str) -> str: + return f"Excluded: {name}" + + mcp.add_transform(VersionFilter(version_lt="2.0", include_unversioned=False)) + + prompts = await mcp.list_prompts() + assert [prompt.name for prompt in prompts] == ["included_versioned_prompt"] + async def test_repr(self): """Test VersionFilter string representation.""" from fastmcp.server.transforms import VersionFilter @@ -252,6 +345,9 @@ class TestVersionFilter: f3 = VersionFilter(version_gte="1.0") assert repr(f3) == "VersionFilter(version_gte='1.0')" + f4 = VersionFilter(version_lt="3.0", include_unversioned=False) + assert repr(f4) == "VersionFilter(version_lt='3.0', include_unversioned=False)" + class TestMountedVersionFiltering: """Tests for version filtering with mounted servers (FastMCPProvider). @@ -354,6 +450,32 @@ class TestMountedVersionFiltering: assert tools[0].name == "child_unversioned_tool" assert tools[0].version is None + async def test_mounted_include_unversioned_false_excludes_unversioned_tools(self): + """Mounted unversioned tools can be excluded with include_unversioned=False.""" + + child = FastMCP("Child") + + @child.tool + def unversioned_tool() -> str: + return "unversioned" + + @child.tool(version="2.0") + def included_versioned_tool() -> str: + return "versioned" + + @child.tool(version="0.5") + def excluded_versioned_tool() -> str: + return "excluded-versioned" + + parent = FastMCP("Parent") + parent.mount(child, "child") + parent.add_transform( + VersionFilter(version_gte="1.0", include_unversioned=False) + ) + + tools = await parent.list_tools() + assert [tool.name for tool in tools] == ["child_included_versioned_tool"] + async def test_version_filter_filters_out_high_mounted_version(self): """VersionFilter hides mounted components outside the range.""" from fastmcp.server.transforms import VersionFilter