Add version discovery and calling a certain version for components#2897
Add version discovery and calling a certain version for components#2897
Conversation
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.
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the 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. ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (3)
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 explicit version selection and propagation across FastMCP. Client methods ( 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. 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.
💡 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".
| # 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, | ||
| }, | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (1)
src/fastmcp/client/client.py (1)
1124-1135: Prompt versioning breaks when_metais JSON‑serialized.Line 1124-1135 serializes non‑string arguments, which turns the
_metadict injected at Line 1213-1222 into a JSON string. The server expects_metato be a dict and calls.get, so this throws and breaks prompt version selection (and any_metapayload). Preserve_metaas a dict (or move version into request meta) in bothget_prompt_mcpand_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 forNotFoundError.Line 273 mentions the server raises
NotFoundErrorwhen 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": "..."})
| To read a specific version, use the `version` parameter: | ||
|
|
||
| ```python | ||
| # Read a specific version | ||
| content = await client.read_resource("data://config", version="1.0") | ||
| ``` |
There was a problem hiding this comment.
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")| 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") | ||
| ``` |
There was a problem hiding this comment.
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")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.
There was a problem hiding this comment.
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
NotFoundErroris 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 NoneIn contrast, other lookup methods like
get_tool,get_resource, andget_promptinclude 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, andcall_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
Test Failure AnalysisSummary: 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 Suggested Solution: Add per-file ignore rules for F811 in [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 AnalysisFailing Files and Errors
Ruff Error OutputThe pattern is consistent: decorators register the functions but don't prevent Python from treating them as redefinitions in the module namespace. Related Files
|
Test Failure AnalysisSummary: 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 ( Suggested Solution: Add per-file ignore rules for F811 in [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 AnalysisFailing Files and Errors
Ruff Error OutputThe pattern is consistent: decorators register the functions but don't prevent Python from treating them as redefinitions in the module namespace. Related Files
|
There was a problem hiding this comment.
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-duplicatemeta.fastmcp.versionsand ensure current version is preserved.If multiple providers contribute the same version,
versionscan include duplicates. Also consider explicitly carryingfastmcp.versionforward 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
| 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__": |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find the file and check its content
fd -e py "version_filters" | head -20Repository: 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:
List operations now attach
meta.fastmcp.versionsto the highest-version component, letting clients discover all available versions before making requests. Client and server APIs accept aversionparameter for explicit version selection.Discovery:
Selection (server API):
Selection (client API):
MCP protocol: Clients can pass
_meta.fastmcp.versionin tool/prompt arguments or resource request params. Only the version field is stripped; other_metacontent is preserved.