Add component versioning and VersionFilter transform#2894
Conversation
Components (tools, resources, prompts) now support a `version` parameter
that enables multiple implementations under the same identifier. Clients
automatically see the highest version, while VersionFilter allows serving
different API versions from a single codebase.
```python
from fastmcp import FastMCP
from fastmcp.server.providers import LocalProvider
from fastmcp.server.transforms import VersionFilter
components = LocalProvider()
@components.tool(version="1.0")
def calculate(x: int, y: int) -> int:
return x + y
@components.tool(version="2.0")
def calculate(x: int, y: int, z: int = 0) -> int:
return x + y + z
# Serve v1 API
api_v1 = FastMCP("API v1", providers=[components])
api_v1.add_transform(VersionFilter(version_lt="2.0"))
# Serve v2 API
api_v2 = FastMCP("API v2", providers=[components])
api_v2.add_transform(VersionFilter(version_gte="2.0"))
```
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2bbcc2fe03
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Test Failure AnalysisSummary: Four telemetry tracing tests are failing because they expect the old component key format without version suffixes. Root Cause: The PR introduces component versioning where component keys now always include an Suggested Solution: Update the failing test assertions in
Detailed AnalysisThe component versioning PR changed the # Line 112 in tests/server/test_versioning.py
assert tools[0].key == "tool:my_tool@" # Version is NoneThe commit message explains: "The @ suffix is ALWAYS present to enable unambiguous parsing of keys (URIs may contain @ characters, so we always include the delimiter)." Failing Tests:
All failures show the same pattern: an Related Files
|
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughThis PR adds component versioning across FastMCP: components (tools, resources, resource templates, prompts, and prompts) gain an optional version field and keys are suffixed with Possibly related PRs
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/development/upgrade-guide.mdx (1)
124-138: Add a brief hint for the trailing “@” key suffix.
Without a note, readers may treat this as a typo instead of “latest version” notation.💡 Suggested tweak
```python After server.disable(keys=["tool:my_tool@"]) server.enable(keys=["tool:my_tool@"]) +# The trailing "@" targets the latest versioned component key.</details> </blockquote></details> </blockquote></details>🧹 Nitpick comments (1)
src/fastmcp/resources/resource.py (1)
233-269: Normalizeversionat this boundary for consistent metadata.
ResourceTemplate.from_functioncoercesversiontostr, butResource.from_functionpasses it through. Normalizing here prevents mixed types and keeps key/version comparisons consistent when callers pass ints.♻️ Suggested tweak
- return FunctionResource.from_function( + normalized_version = str(version) if version is not None else None + return FunctionResource.from_function( fn=fn, uri=uri, name=name, - version=version, + version=normalized_version, title=title, description=description, icons=icons, mime_type=mime_type,
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughThis pull request introduces comprehensive versioning support to FastMCP, enabling multiple implementations of tools, resources, and prompts with the same identifier but different versions. Core changes include adding an optional Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
src/fastmcp/server/tasks/requests.py (1)
51-67: Legacy task keys containing@in URIs remain misparsed.The helper correctly handles new version-sentinel keys (e.g.,
add@1.0,add@), but pre-existing task keys with@in their identifiers (e.g.,resource:mailto:user@example.comwithout the trailing sentinel@) will be incorrectly split. The rsplit would interpretexample.comas the version.Consider detecting the sentinel by checking for a trailing
@pattern or attempting a full-key lookup before splitting:💡 Potential approach
def _parse_key_version(key_suffix: str) -> tuple[str, str | None]: if "@" not in key_suffix: # Legacy key without version sentinel - treat as unversioned return key_suffix, None + # New keys always have @ as sentinel (even unversioned: "name@") + # Legacy keys with @ in URI won't have the trailing @ pattern + # Heuristic: if it ends with "@" or has "@" followed by a valid version-like suffix name_or_uri, version = key_suffix.rsplit("@", 1) return name_or_uri, version if version else NoneAlternatively, try full-key lookup first, falling back to version parsing only if not found.
src/fastmcp/server/transforms/version_filter.py (1)
100-104: Filtered get_ can incorrectly return None when a lower in-range version exists.*
call_next(name)yields the highest overall version; if that version is out of range, the method returns None without checking other versions. This breaks version filtering for get_tool/get_resource/get_resource_template/get_prompt (list_* would show the in-range version, but get_* would fail). Consider selecting from the full version list (e.g., via get_*_versions + _in_range + max) or moving “pick highest” logic to a layer that already sees filtered versions.Also applies to: 120-126, 144-150, 166-170
🧹 Nitpick comments (8)
src/fastmcp/tools/tool_transform.py (1)
899-901: Consider supportinginttype for consistency.Other version parameters in this PR accept
str | int | None(e.g.,Resource.from_function,ResourceMeta.version). This field only acceptsstr | None. While the transformation config is typically provided via JSON/config where integers would be parsed as strings anyway, consider aligning the type for API consistency.♻️ Optional consistency fix
- version: str | None = Field( - default=None, description="The new version for the tool." - ) + version: str | int | None = Field( + default=None, description="The new version for the tool." + )Note: If you accept
int, you'd need to convert it tostrinapply()before passing tofrom_tool().docs/development/upgrade-guide.mdx (1)
124-141: Consider adding an explicit note about the key format change.The change from
"tool:my_tool"to"tool:my_tool@"is a subtle but breaking change. Users who have hardcoded keys in their enable/disable calls will need to add the@suffix. Consider adding a brief explanation of why the format changed (versioning support) to help users understand the migration.📝 Suggested documentation enhancement
### Component Enable/Disable The `enabled` field and `enable()`/`disable()` methods have been removed from component objects. Use server or provider methods instead: +<Note> +Component keys now include a version suffix (e.g., `tool:my_tool@` or `tool:my_tool@1.0`). The `@` is required even for unversioned components. +</Note> + <CodeGroup>docs/servers/versioning.mdx (1)
107-128: Consider adding an example for thevprefix normalization.The documentation mentions that
"v1.0"and"1.0"are treated as equal for sorting, but an explicit example would help clarify this behavior for users who might be unsure whether to include the prefix.📝 Suggested addition after line 128
The `v` prefix is stripped before comparison, so `"v1.0"` and `"1.0"` are treated as equal for sorting purposes. + +```python +# These are equivalent for sorting +@mcp.tool(version="v1.0") # "v" prefix is stripped +@mcp.tool(version="1.0") # Same effective version +```src/fastmcp/utilities/versions.py (1)
45-53: Avoid over‑stripping leading'v'prefixes.
lstrip("v")will remove all leading v’s whenever the string starts with"v", which can unintentionally mangle non‑version strings (e.g.,"version1"→"ersion1"). Consider stripping only a single leadingvwhen it’s clearly a version prefix.♻️ Suggested tweak
- normalized = version.lstrip("v") if version.startswith("v") else version + normalized = ( + version[1:] + if version.startswith("v") and version[1:2].isdigit() + else version + )src/fastmcp/server/providers/openapi/provider.py (1)
356-378: Verify multi‑version behavior for tools/resources.
get_tool/get_resourcenow acceptversion, but the provider still stores a single entry per name/URI. If OpenAPIProvider is expected to expose multiple versions per identifier (per the new versioning model), this will silently drop all but one and won’t pick the highest whenversion=None. Consider storing by(name, version)and delegating to version‑aware selection likeBaseProvider.src/fastmcp/contrib/component_manager/component_service.py (2)
104-108: Simplify the matching condition.The condition
k == key_prefix or k.startswith(key_prefix)is redundant since"tool:name@".startswith("tool:name@")isTrue. Thestartswithcheck alone suffices.♻️ Suggested simplification
matching_keys = [ k for k in self._server._local_provider._components - if k == key_prefix or k.startswith(key_prefix) + if k.startswith(key_prefix) ]This same simplification applies to the identical patterns in
_disable_tool(lines 140-144),_enable_resource(lines 178-187),_disable_resource(lines 224-233),_enable_prompt(lines 271-275), and_disable_prompt(lines 307-311).
111-114: Inconsistent type checking between enable and disable methods.
_enable_tool(line 112) checks onlyif tool is None, while_disable_tool(line 148) checksif tool is None or not isinstance(tool, Tool). The same inconsistency exists between_enable_prompt(line 279) and_disable_prompt(line 315). Consider unifying these checks for consistency.Also applies to: 146-151
src/fastmcp/server/transforms/__init__.py (1)
251-254: Remove unusednoqadirectives.Static analysis indicates the
# noqa: E402comments are unnecessary since E402 isn't enabled. Consider removing them.♻️ Suggested cleanup
# Re-export built-in transforms (must be after Transform class to avoid circular imports) -from fastmcp.server.transforms.namespace import Namespace # noqa: E402 -from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 -from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 -from fastmcp.server.transforms.visibility import Visibility # noqa: E402 +from fastmcp.server.transforms.namespace import Namespace +from fastmcp.server.transforms.tool_transform import ToolTransform +from fastmcp.server.transforms.version_filter import VersionFilter +from fastmcp.server.transforms.visibility import Visibility
- server.get_tool/resource/prompt now accept VersionSpec directly - FastMCPProvider passes full VersionSpec to child servers - VersionSpec.intersect() combines constraints properly - LocalProvider prevents mixing versioned/unversioned components - Removed get_*_versions methods (no longer needed)
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/fastmcp/server/providers/aggregate.py (1)
30-38: Update class docstring to reflect version-aware behavior.The docstring states "first non-None result is returned" but the implementation now returns the highest version across all providers using
_get_highest_version_result. This inconsistency could mislead developers.📝 Suggested docstring update
class AggregateProvider(Provider): """Presents multiple providers as a single provider. - Components are aggregated from all providers. For get_* operations, - providers are queried in parallel and the first non-None result is returned. + Components are aggregated from all providers. For get_* operations, + providers are queried in parallel and the highest version across all + providers is returned. Errors from individual providers are logged and skipped (graceful degradation). This matches the behavior of FastMCP's original provider iteration. """
♻️ Duplicate comments (1)
src/fastmcp/server/transforms/__init__.py (1)
234-237: Remove unusednoqadirectives.Ruff flags these as unused. The E402 rule is not enabled, so these directives serve no purpose.
🧹 Proposed fix
-from fastmcp.server.transforms.namespace import Namespace # noqa: E402 -from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 -from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 -from fastmcp.server.transforms.visibility import Visibility # noqa: E402 +from fastmcp.server.transforms.namespace import Namespace +from fastmcp.server.transforms.tool_transform import ToolTransform +from fastmcp.server.transforms.version_filter import VersionFilter +from fastmcp.server.transforms.visibility import Visibility
🧹 Nitpick comments (1)
src/fastmcp/utilities/versions.py (1)
103-107: Consider validating intersected range bounds.When both specs are ranges, the intersection takes
max(gte)andmin(lt). If the resultinggte >= lt, the spec becomes unsatisfiable but this isn't explicitly detected. Whilematches()will correctly returnFalsefor any version in this case, an explicit check could provide clearer semantics.💡 Optional validation
# Both are ranges - take tighter bounds new_gte = max_version(self.gte, other.gte) new_lt = min_version(self.lt, other.lt) # Check for impossible range (gte >= lt) if new_gte is not None and new_lt is not None: if parse_version_key(new_gte) >= parse_version_key(new_lt): return VersionSpec(eq="__impossible__") return VersionSpec(gte=new_gte, lt=new_lt)
When serving APIs to multiple clients with different requirements, you need a way to expose different versions of the same functionality. Previously, this required separate server deployments or complex routing logic. This PR introduces component versioning—you can register multiple versions of a tool, resource, or prompt, and FastMCP automatically exposes the highest version to clients. Combined with
VersionFilter, you can serve entirely different API surfaces from a single codebase.VersionFilteruses explicit operator semantics (version_gtefor >=,version_ltfor <) so there's no ambiguity about whether bounds are inclusive. Parameters are keyword-only to allow future extensibility. Version comparison supports PEP 440 semantic versioning as well as date strings for teams using date-based versioning schemes.