mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
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:
parent
fccbd43c1e
commit
8a356ad870
3 changed files with 155 additions and 9 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue