Skip to content

Add version discovery and calling a certain version for components#2897

Merged
jlowin merged 7 commits intomainfrom
component-version-selection
Jan 17, 2026
Merged

Add version discovery and calling a certain version for components#2897
jlowin merged 7 commits intomainfrom
component-version-selection

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Jan 17, 2026

List operations now attach meta.fastmcp.versions to the highest-version component, letting clients discover all available versions before making requests. Client and server APIs accept a version parameter for explicit version selection.

Discovery:

tools = await mcp.get_tools()
for tool in tools:
    versions = tool.meta.get("fastmcp", {}).get("versions", [])
    # ["2.0", "1.0"] - all versions, highest first

Selection (server API):

result = await mcp.call_tool("calc", {"x": 5}, version="1.0")
content = await mcp.read_resource("data://info", version="1.0")
messages = await mcp.render_prompt("greet", version="1.0")

Selection (client API):

async with Client(mcp) as client:
    result = await client.call_tool("calc", {"x": 5}, version="1.0")
    content = await client.read_resource("data://info", version="1.0")
    prompt = await client.get_prompt("greet", version="1.0")

MCP protocol: Clients can pass _meta.fastmcp.version in tool/prompt arguments or resource request params. Only the version field is stripped; other _meta content is preserved.

List operations now expose meta.fastmcp.versions on the highest-version
component. Client APIs (call_tool, get_prompt, read_resource) accept a
version parameter. Server MCP handlers extract _meta.fastmcp.version
from requests, preserving other _meta content.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 17, 2026

Warning

Rate limit exceeded

@jlowin has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 52 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 6745eb1 and 871764e.

⛔ Files ignored due to path filters (1)
  • .github/workflows/run-tests.yml is excluded by none and included by none
📒 Files selected for processing (3)
  • examples/versioning/client_version_selection.py
  • examples/versioning/version_filters.py
  • examples/versioning/versioned_components.py

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 explicit version selection and propagation across FastMCP. Client methods (read_resource, get_prompt, call_tool) gain optional version and meta parameters; the client merges the requested version into request-level metadata and sends it as _meta.fastmcp.version. The server introduces _dedupe_with_versions to surface available versions in component meta and threads version through listing, lookup, render, and MCP handler paths; requesting a nonexistent version raises NotFoundError. Documentation and examples are updated to show discovery (meta.fastmcp.version / meta.fastmcp.versions) and how to request specific versions.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.81% 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 clearly and concisely summarizes the main changes: adding version discovery and explicit version selection for components.
Description check ✅ Passed The PR description is complete with clear examples of both server and client API usage, discovery mechanism, and protocol details, though the checklist items are not marked.

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


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.

@marvin-context-protocol marvin-context-protocol Bot added the enhancement Improvement to existing functionality. For issues and smaller PR improvements. label Jan 17, 2026
@jlowin jlowin changed the title Add version discovery and selection for components Add version discovery and calling a certain version for components Jan 17, 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: b4baa85b90

ℹ️ 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/client/client.py Outdated
Comment on lines +1213 to +1222
# Inject version into arguments as _meta.fastmcp.version
args = dict(arguments) if arguments else {}
if version is not None:
args["_meta"] = {
**(args.get("_meta") or {}),
"fastmcp": {
**((args.get("_meta") or {}).get("fastmcp") or {}),
"version": version,
},
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid serializing _meta for prompt versioning

When get_prompt() injects args["_meta"] for the new version parameter, those arguments are later serialized in get_prompt_mcp() by converting non-strings to JSON strings. That turns the _meta dict into a JSON string, but the server’s _get_prompt_mcp() now assumes _meta is a dict and immediately calls meta.get(...), which will raise an AttributeError for any prompt call using version (or any _meta payload). This makes client-side prompt version selection unusable. Consider sending the version via request meta (like resources) or skipping string serialization for _meta so the server can read it as a dict.

Useful? React with 👍 / 👎.

@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Jan 17, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 17, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

♻️ Duplicate comments (1)
src/fastmcp/client/client.py (1)

1124-1135: Prompt versioning breaks when _meta is JSON‑serialized.

Line 1124-1135 serializes non‑string arguments, which turns the _meta dict injected at Line 1213-1222 into a JSON string. The server expects _meta to be a dict and calls .get, so this throws and breaks prompt version selection (and any _meta payload). Preserve _meta as a dict (or move version into request meta) in both get_prompt_mcp and _get_prompt_as_task.

🛠️ Proposed fix (preserve `_meta` as a dict)
-            serialized_arguments: dict[str, str] | None = None
+            serialized_arguments: dict[str, Any] | None = None
             if arguments:
                 serialized_arguments = {}
                 for key, value in arguments.items():
+                    if key == "_meta" and isinstance(value, dict):
+                        serialized_arguments[key] = value
+                        continue
                     if isinstance(value, str):
                         serialized_arguments[key] = value
                     else:
                         # Use pydantic_core.to_json for consistent serialization
                         serialized_arguments[key] = pydantic_core.to_json(value).decode(
                             "utf-8"
                         )

@@
-        serialized_arguments: dict[str, str] | None = None
+        serialized_arguments: dict[str, Any] | None = None
         if arguments:
             serialized_arguments = {}
             for key, value in arguments.items():
+                if key == "_meta" and isinstance(value, dict):
+                    serialized_arguments[key] = value
+                    continue
                 if isinstance(value, str):
                     serialized_arguments[key] = value
                 else:
                     serialized_arguments[key] = pydantic_core.to_json(value).decode(
                         "utf-8"
                     )

Also applies to: 1213-1222, 1252-1262

🧹 Nitpick comments (1)
docs/clients/prompts.mdx (1)

264-274: Consider adding an error handling example for NotFoundError.

Line 273 mentions the server raises NotFoundError when a version doesn't exist, but there's no code example showing how to handle this. As per the coding guidelines, error handling examples and troubleshooting for likely failure points should be included.

📝 Suggested addition after line 271
from mcp.exceptions import NotFoundError

async with client:
    try:
        result = await client.get_prompt("summarize", {"text": "..."}, version="0.5")
    except NotFoundError:
        # Requested version doesn't exist
        print("Version 0.5 not available, using default")
        result = await client.get_prompt("summarize", {"text": "..."})

Comment on lines +106 to +111
To read a specific version, use the `version` parameter:

```python
# Read a specific version
content = await client.read_resource("data://config", version="1.0")
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add error handling and expected output to the versioned read example.

Line 106-111 shows a direct versioned read but omits the NotFoundError path and any verification output. Adding a try/except with a minimal success print makes the snippet runnable and self-checking. As per coding guidelines.

💡 Suggested update
-# Read a specific version
-content = await client.read_resource("data://config", version="1.0")
+from fastmcp.exceptions import NotFoundError
+
+# Read a specific version
+try:
+    content = await client.read_resource("data://config", version="1.0")
+    print(content[0].text)  # Expected: config JSON for v1
+except NotFoundError:
+    print("Version 1.0 not available")

Comment thread docs/clients/tools.mdx
Comment on lines +325 to +334
Pass the `version` parameter to `call_tool()` to execute a specific version instead of the highest.

```python
async with client:
# Call the highest version (default)
result = await client.call_tool("calculate", {"x": 1, "y": 2})

# Call version 1.0 specifically
result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0")
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add error handling for version-specific tool calls.

Line 325-334 uses version without showing the NotFoundError path or a success output for validation. A small try/except keeps the example runnable and self-checking. As per coding guidelines.

💡 Suggested update
 async with client:
     # Call the highest version (default)
     result = await client.call_tool("calculate", {"x": 1, "y": 2})
 
     # Call version 1.0 specifically
-    result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0")
+    from fastmcp.exceptions import NotFoundError
+    try:
+        result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0")
+        print(result_v1.data)  # Expected: 3
+    except NotFoundError:
+        print("Version 1.0 not available")

Comment thread docs/development/v3-notes/v3-features.mdx Outdated
Comment thread docs/servers/versioning.mdx Outdated
Version now flows through MCP's _meta field at the request params level
rather than being injected into arguments. This fixes prompt versioning
(arguments were being serialized to JSON strings) and aligns tools and
resources with the correct MCP protocol pattern.

Also adds meta parameter to get_prompt and read_resource for consistency
with call_tool, allowing users to pass their own metadata while version
info is merged into the fastmcp namespace.
@jlowin jlowin added feature Major new functionality. Reserved for 2-4 significant PRs per release. Not for issues. and removed enhancement Improvement to existing functionality. For issues and smaller PR improvements. labels Jan 17, 2026
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: 0

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/server.py (1)

1774-1792: Minor: Consider including version in resource lookup error messages.

When the resource lookup fails at line 1774, the NotFoundError is caught and the code falls through to try templates. However, if template lookup also fails (line 1792-1794), the error message doesn't include version information:

raise NotFoundError(f"Unknown resource: {uri!r}") from None

In contrast, other lookup methods like get_tool, get_resource, and get_prompt include version info in their error messages. If this asymmetry is intentional (e.g., to avoid revealing version information to external clients), consider adding a brief comment explaining the design choice.

🧹 Nitpick comments (1)
src/fastmcp/client/client.py (1)

1226-1241: Optional: Extract version merging logic into a helper function.

The version merging pattern is repeated in read_resource, get_prompt, and call_tool:

request_meta = dict(meta) if meta else {}
if version is not None:
    request_meta["fastmcp"] = {
        **request_meta.get("fastmcp", {}),
        "version": version,
    }

Consider extracting this into a small helper for DRY.

♻️ Example helper function
def _merge_version_into_meta(
    meta: dict[str, Any] | None,
    version: str | None,
) -> dict[str, Any] | None:
    """Merge version into request-level meta under fastmcp namespace."""
    if version is None and meta is None:
        return None
    request_meta = dict(meta) if meta else {}
    if version is not None:
        request_meta["fastmcp"] = {
            **request_meta.get("fastmcp", {}),
            "version": version,
        }
    return request_meta or None

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The static analysis (Ruff) is failing because the example files intentionally redefine functions with the same name to demonstrate versioning, which triggers Ruff's F811 rule (redefinition of unused variables).

Root Cause: In the three example files (, , ), functions are defined multiple times with the same name but different @mcp.tool, @mcp.prompt, or @components.tool decorators with different version parameters. While the decorators register each version, Python still sees these as function redefinitions at the module level, which Ruff flags as F811 errors.

Suggested Solution: Add per-file ignore rules for F811 in pyproject.toml for the versioning example files:

[tool.ruff.lint.per-file-ignores]
"examples/versioning/*.py" = ["F811"]

This tells Ruff to ignore function redefinition warnings in these specific example files where redefinition is intentional and demonstrates the versioning feature.

Detailed Analysis

Failing Files and Errors

  1. client_version_selection.py:21,27,33 - Three versions of charge() function
  2. version_filters.py:24,30,36 - Three versions of process() function
  3. versioned_components.py:25,31 - Two versions of calculate() function
  4. versioned_components.py:55,60 - Two versions of summarize() function

Ruff Error Output

F811 Redefinition of unused `charge` from line 21
F811 Redefinition of unused `charge` from line 27
F811 Redefinition of unused `process` from line 24
F811 Redefinition of unused `process` from line 30
F811 Redefinition of unused `calculate` from line 25
F811 Redefinition of unused `summarize` from line 55

The pattern is consistent: decorators register the functions but don't prevent Python from treating them as redefinitions in the module namespace.

Related Files
  • examples/versioning/client_version_selection.py - Demonstrates client-side version selection with multiple charge() function versions
  • examples/versioning/version_filters.py - Shows version filtering with multiple process() function versions
  • examples/versioning/versioned_components.py - Shows versioned tools, resources, and prompts with calculate() and summarize() redefinitions
  • pyproject.toml - Ruff configuration (lines 159-189) where per-file ignores should be added

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The static analysis (Ruff) is failing because the example files intentionally redefine functions with the same name to demonstrate versioning, which triggers Ruff's F811 rule (redefinition of unused variables).

Root Cause: In the three example files (client_version_selection.py, version_filters.py, versioned_components.py), functions are defined multiple times with the same name but different @mcp.tool, @mcp.prompt, or @components.tool decorators with different version parameters. While the decorators register each version, Python still sees these as function redefinitions at the module level, which Ruff flags as F811 errors.

Suggested Solution: Add per-file ignore rules for F811 in pyproject.toml for the versioning example files:

[tool.ruff.lint.per-file-ignores]
"examples/versioning/*.py" = ["F811"]

This tells Ruff to ignore function redefinition warnings in these specific example files where redefinition is intentional and demonstrates the versioning feature.

Detailed Analysis

Failing Files and Errors

  1. client_version_selection.py:21,27,33 - Three versions of charge() function
  2. version_filters.py:24,30,36 - Three versions of process() function
  3. versioned_components.py:25,31 - Two versions of calculate() function
  4. versioned_components.py:55,60 - Two versions of summarize() function

Ruff Error Output

F811 Redefinition of unused `charge` from line 21
F811 Redefinition of unused `charge` from line 27
F811 Redefinition of unused `process` from line 24
F811 Redefinition of unused `process` from line 30
F811 Redefinition of unused `calculate` from line 25
F811 Redefinition of unused `summarize` from line 55

The pattern is consistent: decorators register the functions but don't prevent Python from treating them as redefinitions in the module namespace.

Related Files
  • examples/versioning/client_version_selection.py - Demonstrates client-side version selection with multiple charge() function versions
  • examples/versioning/version_filters.py - Shows version filtering with multiple process() function versions
  • examples/versioning/versioned_components.py - Shows versioned tools, resources, and prompts with calculate() and summarize() redefinitions
  • pyproject.toml - Ruff configuration (lines 159-189) where per-file ignores should be added

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/development/v3-notes/v3-features.mdx (1)

234-283: Make the versioning examples runnable and second-person.

These snippets are partial (no imports/runner, no error handling or expected output) and the instructional text is third-person. Expand this into a complete, runnable example with basic error handling and a sample output block, and rephrase the instructions using “you.” As per coding guidelines, please keep MDX examples runnable and user-oriented.

🧹 Nitpick comments (1)
src/fastmcp/server/server.py (1)

106-110: De-duplicate meta.fastmcp.versions and ensure current version is preserved.

If multiple providers contribute the same version, versions can include duplicates. Also consider explicitly carrying fastmcp.version forward when injecting the list so callers always see the current version alongside the list.

♻️ Suggested refinement
-            all_versions = sorted(
-                [c.version for c in versions if c.version is not None],
+            all_versions = sorted(
+                {c.version for c in versions if c.version is not None},
                 key=parse_version_key,
                 reverse=True,
             )
             meta = highest.meta or {}
+            fastmcp_meta = meta.get("fastmcp", {})
             highest = highest.model_copy(
                 update={
                     "meta": {
                         **meta,
                         "fastmcp": {
-                            **meta.get("fastmcp", {}),
+                            **fastmcp_meta,
+                            "version": fastmcp_meta.get("version", highest.version),
                             "versions": all_versions,
                         },
                     }
                 }
             )

Also applies to: 194-239

Comment thread examples/versioning/client_version_selection.py
Comment on lines +60 to +90
async def show_surface(name: str, server: FastMCP):
"""Show what's visible through a filtered server."""
async with Client(server) as client:
tools = await client.list_tools()

table = Table(title=name)
table.add_column("Tool")
table.add_column("Version", style="green")

for tool in tools:
meta = tool.meta.get("fastmcp", {}) if tool.meta else {}
table.add_row(tool.name, meta.get("version", "(unversioned)"))

print(table)


async def main():
# Show what each API surface exposes
await show_surface("API v1 (version_lt='2.0')", api_v1)
await show_surface("API v2 (version_gte='2.0', version_lt='3.0')", api_v2)
await show_surface("API v3 (version_gte='3.0')", api_v3)

# Same tool name, different behavior per API
print("\n[bold]Same call through different APIs:[/]")
for name, server in [("v1", api_v1), ("v2", api_v2), ("v3", api_v3)]:
async with Client(server) as client:
result = await client.call_tool("process", {"data": "Hello"})
print(f" API {name}: process('Hello') -> '{result.data}'")


if __name__ == "__main__":
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the file and check its content
fd -e py "version_filters" | head -20

Repository: jlowin/fastmcp

Length of output: 172


🌐 Web query:

fastmcp examples/versioning/version_filters.py async def show_surface main

💡 Result:

Do you want me to (pick one)?

  • show the source code for examples/versioning/version_filters.py (if it exists)
  • explain what async def show_surface / main do in that file
  • find and open that file in the fastmcp GitHub repo and paste the relevant function(s)

Which one?


🌐 Web query:

show the source code for examples/versioning/version_filters.py from jlowin/fastmcp GitHub repository

💡 Result:

I tried to fetch that file from the jlowin/fastmcp GitHub repo but couldn't load the specific file page. Do you want me to try again to retrieve and display examples/versioning/version_filters.py from the repository?


Add explicit return type annotations for async functions.

These async functions lack return type annotations as required by the coding guidelines. Both show_surface and main return None implicitly and should be explicitly annotated.

🛠️ Proposed fix
-async def show_surface(name: str, server: FastMCP):
+async def show_surface(name: str, server: FastMCP) -> None:
@@
-async def main():
+async def main() -> None:

Comment thread examples/versioning/versioned_components.py
Comment thread examples/versioning/versioned_components.py
@jlowin jlowin merged commit 33c371c into main Jan 17, 2026
16 checks passed
@jlowin jlowin deleted the component-version-selection branch January 17, 2026 13:27
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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant