Add include_unversioned option to VersionFilter (#3349)

* Add include_unversioned option to VersionFilter

* test: add include_unversioned=False coverage for prompts/templates; align naming/imports
This commit is contained in:
yangbaechu 2026-03-03 11:06:50 +09:00 committed by GitHub
commit 8a356ad870
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 155 additions and 9 deletions

View file

@ -60,7 +60,7 @@ VersionFilter(version_gte="2.0", version_lt="3.0")
```
<Note>
**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.
</Note>
### 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.

View file

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

View file

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