Skip to content

Add component versioning and VersionFilter transform#2894

Merged
jlowin merged 5 commits intomainfrom
component-versioning
Jan 17, 2026
Merged

Add component versioning and VersionFilter transform#2894
jlowin merged 5 commits intomainfrom
component-versioning

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Jan 16, 2026

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.

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

# Two servers from one codebase
api_v1 = FastMCP("API v1", providers=[components])
api_v1.add_transform(VersionFilter(version_lt="2.0"))

api_v2 = FastMCP("API v2", providers=[components])
api_v2.add_transform(VersionFilter(version_gte="2.0"))

VersionFilter uses explicit operator semantics (version_gte for >=, version_lt for <) 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.

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"))
```
@marvin-context-protocol marvin-context-protocol Bot added feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. server Related to FastMCP server implementation or server-side functionality. labels Jan 16, 2026
@jlowin jlowin added the v3 Targeted for FastMCP 3 label Jan 16, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/fastmcp/server/transforms/version_filter.py Outdated
Comment thread src/fastmcp/server/tasks/requests.py
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: 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 @ suffix (e.g., tool:greet@ or tool:greet@2.0). The telemetry tests in tests/server/telemetry/test_server_tracing.py were not updated to reflect this new key format and still expect the old format without the @ suffix.

Suggested Solution: Update the failing test assertions in tests/server/telemetry/test_server_tracing.py to expect the new key format with the @ suffix:

  1. Line 43 - Change assert span.attributes["fastmcp.component.key"] == "tool:greet" to "tool:greet@"
  2. Line 111 - Change assert span.attributes["fastmcp.component.key"] == "resource:config://app" to "resource:config://app@"
  3. Line 140-141 - Change assert span.attributes["fastmcp.component.key"] == "template:users://{user_id}/profile" to "template:users://{user_id}/profile@"
  4. Line 189 - Change assert span.attributes["fastmcp.component.key"] == "prompt:greeting" to "prompt:greeting@"
Detailed Analysis

The component versioning PR changed the key property in FastMCPComponent (src/fastmcp/utilities/components.py:106-119) to always append @{version} to component keys. From the PR's own test file (tests/server/test_versioning.py), we can see this is intentional:

# Line 112 in tests/server/test_versioning.py
assert tools[0].key == "tool:my_tool@"  # Version is None

The 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:

  • TestToolTracing::test_call_tool_creates_span - Expected tool:greet, got tool:greet@
  • TestResourceTracing::test_read_resource_creates_span - Expected resource:config://app, got resource:config://app@
  • TestResourceTracing::test_read_resource_template_creates_span - Expected template:users://{user_id}/profile, got template:users://{user_id}/profile@
  • TestPromptTracing::test_render_prompt_creates_span - Expected prompt:greeting, got prompt:greeting@

All failures show the same pattern: an @ character is now appended to component keys.

Related Files
  • src/fastmcp/utilities/components.py:106-119 - Changed key property to include @ suffix
  • tests/server/telemetry/test_server_tracing.py:43,111,140-141,189 - Assertions need updating
  • tests/server/test_versioning.py - Shows the new key format is intentional

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 16, 2026

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This PR adds component versioning across FastMCP: components (tools, resources, resource templates, prompts, and prompts) gain an optional version field and keys are suffixed with @<version> (or @ for unversioned). It introduces a VersionSpec/VersionKey utilities for comparison and sorting, a VersionFilter transform for range filtering, and makes provider/server/getter/list/remove/enable/disable flows version-aware (including new get_*_versions APIs and highest-version selection). Local, aggregate, filesystem, OpenAPI, and provider-wrapper layers are updated to propagate and resolve version constraints through transforms and lookups.

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.99% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is comprehensive and well-structured, covering the motivation, use case, code example, and key design decisions (keyword-only parameters, version comparison semantics). However, it does not follow the provided template format with the required contributors and review checklists. Add the contributors and review checklists from the template to ensure compliance with the repository's PR submission process.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: introducing component versioning and a VersionFilter transform, which are the primary features added in this PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Normalize version at this boundary for consistent metadata.

ResourceTemplate.from_function coerces version to str, but Resource.from_function passes 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,

Comment thread docs/development/v3-notes/v3-features.mdx
Comment thread docs/servers/resources.mdx
Comment thread docs/servers/tools.mdx
Comment thread docs/servers/versioning.mdx Outdated
Comment thread docs/servers/versioning.mdx
Comment thread docs/servers/versioning.mdx
Comment thread src/fastmcp/contrib/component_manager/component_service.py
Comment thread src/fastmcp/resources/resource.py
Comment thread src/fastmcp/resources/template.py
Comment thread src/fastmcp/server/transforms/__init__.py
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 16, 2026

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

This 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 version field to component metadata and registration APIs, implementing version-aware lookups throughout the provider and server layers, changing component keys to include version suffixes (e.g., tool:my_tool@), introducing a new VersionFilter transform for filtering by version ranges, and providing utilities for version comparison and sorting. The system defaults to returning the highest version when no specific version is requested, supports PEP 440 version comparison, and includes migrations paths for managing multiple versions in production.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.60% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changes: adding component versioning and introducing the VersionFilter transform.
Description check ✅ Passed The PR description is comprehensive and includes clear motivation, concrete code examples, and explains the VersionFilter semantics and version comparison approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.com without the trailing sentinel @) will be incorrectly split. The rsplit would interpret example.com as 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 None

Alternatively, 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 supporting int type for consistency.

Other version parameters in this PR accept str | int | None (e.g., Resource.from_function, ResourceMeta.version). This field only accepts str | 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 to str in apply() before passing to from_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 the v prefix 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 leading v when 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_resource now accept version, 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 when version=None. Consider storing by (name, version) and delegating to version‑aware selection like BaseProvider.

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@") is True. The startswith check 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 only if tool is None, while _disable_tool (line 148) checks if 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 unused noqa directives.

Static analysis indicates the # noqa: E402 comments 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

Comment thread src/fastmcp/contrib/component_manager/component_service.py
Comment thread src/fastmcp/contrib/component_manager/component_service.py
- 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)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 unused noqa directives.

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) and min(lt). If the resulting gte >= lt, the spec becomes unsatisfiable but this isn't explicitly detected. While matches() will correctly return False for 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)

Comment thread src/fastmcp/server/server.py
@jlowin jlowin merged commit a6cd764 into main Jan 17, 2026
11 checks passed
@jlowin jlowin deleted the component-versioning branch January 17, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. server Related to FastMCP server implementation or server-side functionality. v3 Targeted for FastMCP 3

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant