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

This commit is contained in:
Jeremiah Lowin 2026-01-17 08:27:39 -05:00 committed by GitHub
commit 33c371cb37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 989 additions and 76 deletions

View file

@ -53,7 +53,7 @@ jobs:
- name: Run tests (excluding integration and client_process)
run: |
if [ "${{ matrix.os }}" == "windows-latest" ]; then
uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" -v
uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process"
else
uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal
fi

View file

@ -85,12 +85,29 @@ async with client:
"name": "Alice",
"role": "administrator"
})
# Access the personalized messages
for message in result.messages:
print(f"Generated message: {message.content}")
```
### Requesting Specific Versions
<VersionBadge version="3.0.0" />
When a server has multiple versions of a prompt, you can request a specific version instead of the default (highest) version.
```python
async with client:
# Get the highest version (default)
result = await client.get_prompt("summarize", {"text": "..."})
# Get a specific version
result_v1 = await client.get_prompt("summarize", {"text": "..."}, version="1.0")
```
To discover available versions, check the `meta.fastmcp.versions` field when listing prompts. See [Version Selection](#version-selection) below for more details.
## Automatic Argument Serialization
<VersionBadge version="2.9.0" />
@ -213,4 +230,48 @@ async with client:
<Tip>
Prompt arguments and their expected types depend on the specific prompt implementation. Check the server's documentation or use `list_prompts()` to see available arguments for each prompt.
</Tip>
</Tip>
## Version Selection
<VersionBadge version="3.0.0" />
FastMCP servers can expose multiple versions of the same prompt. By default, clients receive and request the highest version, but you can request a specific version when needed.
### Discovering Versions
When a server registers multiple versions of a prompt, the `list_prompts()` response includes version information in the metadata. The `meta.fastmcp.version` field shows which version is being returned, while `meta.fastmcp.versions` lists all available versions sorted from highest to lowest.
```python
async with client:
prompts = await client.list_prompts()
for prompt in prompts:
if prompt.meta:
fastmcp_meta = prompt.meta.get("fastmcp", {})
version = fastmcp_meta.get("version")
all_versions = fastmcp_meta.get("versions")
if all_versions:
print(f"{prompt.name}: v{version} (available: {all_versions})")
```
Unversioned prompts omit these metadata fields entirely.
### Getting Specific Versions
Pass the `version` parameter to `get_prompt()` to render a specific version instead of the highest.
```python
async with client:
# Get the highest version (default)
result = await client.get_prompt("summarize", {"text": "..."})
# Get version 1.0 specifically
result_v1 = await client.get_prompt("summarize", {"text": "..."}, version="1.0")
```
If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version.
<Note>
Version selection is a FastMCP extension to the MCP protocol. See [Versioning](/servers/versioning#requesting-specific-versions) for details on how this works at the protocol level for non-FastMCP clients.
</Note>

View file

@ -81,9 +81,35 @@ async with client:
```
<Note>
The `meta` field is part of the standard MCP specification. FastMCP servers always include tags and other metadata within a `fastmcp` namespace (e.g., `meta.fastmcp.tags`) to avoid conflicts with user-defined metadata. Component versions are also included in the metadata when available (e.g., `meta.fastmcp.version`). Other MCP server implementations may not provide this metadata structure.
The `meta` field is part of the standard MCP specification. FastMCP servers always include tags and other metadata within a `fastmcp` namespace (e.g., `meta.fastmcp.tags`) to avoid conflicts with user-defined metadata. For versioned resources, `meta.fastmcp.version` shows the current version and `meta.fastmcp.versions` lists all available versions. Other MCP server implementations may not provide this metadata structure.
</Note>
### Version Information
<VersionBadge version="3.0.0" />
When a server registers multiple versions of a resource, the metadata includes version information.
```python
async with client:
resources = await client.list_resources()
for resource in resources:
if resource.meta:
fastmcp_meta = resource.meta.get("fastmcp", {})
version = fastmcp_meta.get("version")
all_versions = fastmcp_meta.get("versions")
if all_versions:
print(f"{resource.uri}: v{version} (available: {all_versions})")
```
To read a specific version, use the `version` parameter:
```python
# Read a specific version
content = await client.read_resource("data://config", version="1.0")
```
## Reading Resources
### Static Resources

View file

@ -99,6 +99,7 @@ async with client:
**Parameters:**
- `name`: The tool name (string)
- `arguments`: Dictionary of arguments to pass to the tool (optional)
- `version`: Specific tool version to call (optional, see [Version Selection](#version-selection) below)
- `timeout`: Maximum execution time in seconds (optional, overrides client-level timeout)
- `progress_handler`: Progress callback function (optional, overrides client-level handler)
- `meta`: Dictionary of metadata to send with the request (optional, see below)
@ -292,4 +293,48 @@ async with client:
<Tip>
For multi-server clients, tool names are automatically prefixed with the server name (e.g., `weather_get_forecast` for a tool named `get_forecast` on the `weather` server).
</Tip>
</Tip>
## Version Selection
<VersionBadge version="3.0.0" />
FastMCP servers can expose multiple versions of the same tool. By default, clients receive and call the highest version, but you can request a specific version when needed.
### Discovering Versions
When a server registers multiple versions of a tool, the `list_tools()` response includes version information in the metadata. The `meta.fastmcp.version` field shows which version is being returned, while `meta.fastmcp.versions` lists all available versions sorted from highest to lowest.
```python
async with client:
tools = await client.list_tools()
for tool in tools:
if tool.meta:
fastmcp_meta = tool.meta.get("fastmcp", {})
version = fastmcp_meta.get("version")
all_versions = fastmcp_meta.get("versions")
if all_versions:
print(f"{tool.name}: v{version} (available: {all_versions})")
```
Unversioned tools omit these metadata fields entirely.
### Calling Specific Versions
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")
```
If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version.
<Note>
Version selection is a FastMCP extension to the MCP protocol. See [Versioning](/servers/versioning#requesting-specific-versions) for details on how this works at the protocol level for non-FastMCP clients.
</Note>

View file

@ -231,7 +231,18 @@ def add(x: int, y: int, z: int = 0) -> int:
- Unversioned components sort lower than any versioned component
- The `v` prefix is normalized (`v1.0` equals `1.0`)
**Retrieving specific versions:**
**Version visibility in meta:**
List operations expose all available versions in the component's `meta` field:
```python
tools = await client.list_tools()
# Each tool's meta includes:
# - meta["fastmcp"]["version"]: the version of this component ("2.0")
# - meta["fastmcp"]["versions"]: all available versions ["2.0", "1.0"]
```
**Retrieving and calling specific versions:**
```python
# Get the highest version (default)
@ -240,8 +251,35 @@ tool = await server.get_tool("add")
# Get a specific version
tool_v1 = await server.get_tool("add", version="1.0")
# Get all versions
all_versions = await server.get_tool_versions("add")
# Call a specific version
result = await server.call_tool("add", {"x": 1, "y": 2}, version="1.0")
```
**Client version requests:**
The FastMCP client supports version selection:
```python
async with Client(server) as client:
# Call specific tool version
result = await client.call_tool("add", {"x": 1, "y": 2}, version="1.0")
# Get specific prompt version
prompt = await client.get_prompt("my_prompt", {"text": "..."}, version="2.0")
```
For generic MCP clients, pass version via `_meta` in arguments:
```json
{
"x": 1,
"y": 2,
"_meta": {
"fastmcp": {
"version": "1.0"
}
}
}
```
**VersionFilter transform:**

View file

@ -157,9 +157,83 @@ def summarize(text: str, style: str = "concise") -> str:
return f"Summarize in a {style} style: {text}"
```
<Note>
Clients always receive the highest version of each component. Versioning controls what the server exposes, not what clients request.
</Note>
### Version Discovery
When clients list components, each versioned component includes metadata about all available versions. This lets clients discover what versions exist before deciding which to use. The `meta.fastmcp.versions` field contains all registered versions sorted from highest to lowest.
```python
from fastmcp import Client
async with Client(server) as client:
tools = await client.list_tools()
for tool in tools:
if tool.meta:
fastmcp_meta = tool.meta.get("fastmcp", {})
# Current version being returned (highest by default)
print(f"Version: {fastmcp_meta.get('version')}")
# All available versions for this component
print(f"Available: {fastmcp_meta.get('versions')}")
```
For a tool with versions `"1.0"` and `"2.0"`, listing returns the `2.0` implementation with `meta.fastmcp.version` set to `"2.0"` and `meta.fastmcp.versions` set to `["2.0", "1.0"]`. Unversioned components omit these fields entirely.
This discovery mechanism enables clients to make informed decisions about which version to request, support graceful degradation when newer versions introduce breaking changes, or display version information in developer tools.
## Requesting Specific Versions
By default, clients receive and invoke the highest version of each component. When you need a specific version, FastMCP provides two approaches: the FastMCP client API for Python applications, and the MCP protocol mechanism for any MCP-compatible client.
### FastMCP Client
The FastMCP client's `call_tool` and `get_prompt` methods accept an optional `version` parameter. When specified, the server executes that exact version instead of the highest.
```python
from fastmcp import Client
async with Client(server) as client:
# Call the highest version (default behavior)
result = await client.call_tool("calculate", {"x": 1, "y": 2})
# Call a specific version
result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0")
# Get a specific prompt version
prompt = await client.get_prompt("summarize", {"text": "..."}, version="1.0")
```
If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version.
### MCP Protocol
For generic MCP clients that don't have built-in version support, pass the version through the `_meta` field in arguments. FastMCP servers extract the version from `_meta.fastmcp.version` before processing.
<CodeGroup>
```json Tool Call Arguments
{
"x": 1,
"y": 2,
"_meta": {
"fastmcp": {
"version": "1.0"
}
}
}
```
```json Prompt Arguments
{
"text": "Summarize this document...",
"_meta": {
"fastmcp": {
"version": "1.0"
}
}
}
```
</CodeGroup>
The `_meta` field is part of the MCP request params, not arguments, so your component implementation never sees it. This convention allows version selection to work across any MCP client without requiring protocol changes. The FastMCP client handles this automatically when you pass the `version` parameter.
## Version Comparison

View file

@ -0,0 +1,81 @@
"""
Client-Side Version Selection
Discover available versions via metadata and request specific versions
when calling tools, prompts, or resources.
Run: uv run python examples/versioning/client_version_selection.py
"""
import asyncio
from rich import print
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
mcp = FastMCP("Payment API")
@mcp.tool(version="1.0")
def charge(amount: int, currency: str = "USD") -> dict:
"""Charge a payment (v1.0 - basic)."""
return {"status": "charged", "amount": amount, "currency": currency}
@mcp.tool(version="1.1")
def charge( # noqa: F811
amount: int, currency: str = "USD", idempotency_key: str | None = None
) -> dict:
"""Charge a payment (v1.1 - added idempotency)."""
return {"status": "charged", "amount": amount, "idempotency_key": idempotency_key}
@mcp.tool(version="2.0")
def charge( # noqa: F811
amount: int,
currency: str = "USD",
idempotency_key: str | None = None,
metadata: dict | None = None,
) -> dict:
"""Charge a payment (v2.0 - added metadata)."""
return {"status": "charged", "amount": amount, "metadata": metadata or {}}
async def main():
async with Client(mcp) as client:
# Discover versions via metadata
tools = await client.list_tools()
tool = tools[0]
meta = tool.meta.get("fastmcp", {})
print(f"[bold]{tool.name}[/]")
print(f" Current version: [green]{meta.get('version')}[/]")
print(f" All versions: {meta.get('versions')}")
# Call specific versions
print("\n[bold]Calling specific versions:[/]")
r1 = await client.call_tool("charge", {"amount": 100}, version="1.0")
print(f" v1.0: {r1.data}")
r1_1 = await client.call_tool(
"charge", {"amount": 100, "idempotency_key": "abc"}, version="1.1"
)
print(f" v1.1: {r1_1.data}")
r2 = await client.call_tool(
"charge", {"amount": 100, "metadata": {"order": "xyz"}}, version="2.0"
)
print(f" v2.0: {r2.data}")
# Handle missing versions
print("\n[bold]Missing version:[/]")
try:
await client.call_tool("charge", {"amount": 100}, version="99.0")
except ToolError as e:
print(f" [red]ToolError:[/] {e}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,91 @@
"""
Version Filters for API Surfaces
Use VersionFilter to create distinct API surfaces from shared components.
This lets you serve v1, v2, v3 APIs from a single codebase.
Run: uv run python examples/versioning/version_filters.py
"""
import asyncio
from rich import print
from rich.table import Table
from fastmcp import Client, FastMCP
from fastmcp.server.providers import LocalProvider
from fastmcp.server.transforms import VersionFilter
# Shared component pool with all versions
components = LocalProvider()
@components.tool(version="1.0")
def process(data: str) -> str:
"""Process data (v1 - uppercase only)."""
return data.upper()
@components.tool(version="2.0")
def process(data: str, mode: str = "upper") -> str: # noqa: F811
"""Process data (v2 - with mode selection)."""
return data.lower() if mode == "lower" else data.upper()
@components.tool(version="3.0")
def process(data: str, mode: str = "upper", repeat: int = 1) -> str: # noqa: F811
"""Process data (v3 - with repeat)."""
result = data.lower() if mode == "lower" else data.upper()
return result * repeat
# Unversioned components pass through all filters
@components.tool()
def health() -> str:
"""Health check (always available)."""
return "ok"
# Create filtered API surfaces
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", version_lt="3.0"))
api_v3 = FastMCP("API v3", providers=[components])
api_v3.add_transform(VersionFilter(version_gte="3.0"))
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__":
asyncio.run(main())

View file

@ -0,0 +1,101 @@
"""
Creating Versioned Components
Register multiple versions of the same tool, resource, or prompt.
Clients see the highest version by default but can request specific versions.
Run: uv run python examples/versioning/versioned_components.py
"""
import asyncio
from rich import print
from rich.table import Table
from fastmcp import Client, FastMCP
mcp = FastMCP("Versioned API")
# --- Versioned Tools ---
# Same name, different versions with different signatures
@mcp.tool(version="1.0")
def calculate(x: int, y: int) -> int:
"""Add two numbers (v1.0)."""
return x + y
@mcp.tool(version="2.0")
def calculate(x: int, y: int, z: int = 0) -> int: # noqa: F811
"""Add two or three numbers (v2.0)."""
return x + y + z
# --- Versioned Resources ---
# Same URI, different content per version
@mcp.resource("config://app", version="1.0")
def config_v1() -> str:
return '{"format": "legacy"}'
@mcp.resource("config://app", version="2.0")
def config_v2() -> str:
return '{"format": "modern", "telemetry": true}'
# --- Versioned Prompts ---
# Same prompt, different templates per version
@mcp.prompt(version="1.0")
def summarize(text: str) -> str:
return f"Summarize: {text}"
@mcp.prompt(version="2.0")
def summarize(text: str, style: str = "concise") -> str: # noqa: F811
return f"Summarize in a {style} style: {text}"
async def main():
async with Client(mcp) as client:
# List components - clients see highest version + all available versions
tools = await client.list_tools()
table = Table(title="Components (as seen by clients)")
table.add_column("Type")
table.add_column("Name")
table.add_column("Version", style="green")
table.add_column("All Versions", style="dim")
for tool in tools:
meta = tool.meta.get("fastmcp", {}) if tool.meta else {}
table.add_row(
"Tool",
tool.name,
meta.get("version"),
", ".join(meta.get("versions", [])),
)
print(table)
# Call specific versions
print("\n[bold]Calling specific versions:[/]")
r_default = await client.call_tool("calculate", {"x": 5, "y": 3})
r_v1 = await client.call_tool("calculate", {"x": 5, "y": 3}, version="1.0")
r_v2 = await client.call_tool(
"calculate", {"x": 5, "y": 3, "z": 2}, version="2.0"
)
print(f" calculate(5, 3) -> {r_default.data} (default: highest)")
print(f" calculate(5, 3) v1.0 -> {r_v1.data}")
print(f" calculate(5, 3, 2) v2.0 -> {r_v2.data}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -902,7 +902,7 @@ class Client(Generic[ClientTransportT]):
params=mcp.types.ReadResourceRequestParams(
uri=uri,
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
_meta=propagated_meta, # ty: ignore[unknown-argument]
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
result = await self._await_with_session_monitoring(
@ -922,6 +922,8 @@ class Client(Generic[ClientTransportT]):
self,
uri: AnyUrl | str,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[False] = False,
) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: ...
@ -930,6 +932,8 @@ class Client(Generic[ClientTransportT]):
self,
uri: AnyUrl | str,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[True],
task_id: str | None = None,
ttl: int = 60000,
@ -939,6 +943,8 @@ class Client(Generic[ClientTransportT]):
self,
uri: AnyUrl | str,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: bool = False,
task_id: str | None = None,
ttl: int = 60000,
@ -950,6 +956,8 @@ class Client(Generic[ClientTransportT]):
Args:
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
version (str | None): Specific version to read. If None, reads highest version.
meta (dict[str, Any] | None): Optional request-level metadata.
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
ttl (int): Time to keep results available in milliseconds (default 60s).
@ -962,8 +970,18 @@ class Client(Generic[ClientTransportT]):
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
# Merge version into request-level meta (not arguments)
request_meta = dict(meta) if meta else {}
if version is not None:
request_meta["fastmcp"] = {
**request_meta.get("fastmcp", {}),
"version": version,
}
if task:
return await self._read_resource_as_task(uri, task_id, ttl)
return await self._read_resource_as_task(
uri, task_id, ttl, meta=request_meta or None
)
if isinstance(uri, str):
try:
@ -972,7 +990,7 @@ class Client(Generic[ClientTransportT]):
raise ValueError(
f"Provided resource URI is invalid: {str(uri)!r}"
) from e
result = await self.read_resource_mcp(uri)
result = await self.read_resource_mcp(uri, meta=request_meta or None)
return result.contents
async def _read_resource_as_task(
@ -980,6 +998,7 @@ class Client(Generic[ClientTransportT]):
uri: AnyUrl | str,
task_id: str | None = None,
ttl: int = 60000,
meta: dict[str, Any] | None = None,
) -> ResourceTask:
"""Read a resource for background execution (SEP-1686).
@ -989,6 +1008,7 @@ class Client(Generic[ClientTransportT]):
uri: Resource URI to read
task_id: Optional client-provided task ID (ignored, for backward compatibility)
ttl: Time to keep results available in milliseconds (default 60s)
meta: Optional metadata to pass with the request (e.g., version info)
Returns:
ResourceTask: Future-like object for accessing task status and results
@ -1001,6 +1021,7 @@ class Client(Generic[ClientTransportT]):
params=mcp.types.ReadResourceRequestParams(
uri=uri,
task=mcp.types.TaskMetadata(ttl=ttl),
_meta=meta, # type: ignore[unknown-argument] # pydantic alias
)
)
@ -1133,7 +1154,7 @@ class Client(Generic[ClientTransportT]):
name=name,
arguments=serialized_arguments,
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
_meta=propagated_meta, # ty: ignore[unknown-argument]
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias
)
)
result = await self._await_with_session_monitoring(
@ -1154,6 +1175,8 @@ class Client(Generic[ClientTransportT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[False] = False,
) -> mcp.types.GetPromptResult: ...
@ -1163,6 +1186,8 @@ class Client(Generic[ClientTransportT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: Literal[True],
task_id: str | None = None,
ttl: int = 60000,
@ -1173,6 +1198,8 @@ class Client(Generic[ClientTransportT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
meta: dict[str, Any] | None = None,
task: bool = False,
task_id: str | None = None,
ttl: int = 60000,
@ -1182,6 +1209,8 @@ class Client(Generic[ClientTransportT]):
Args:
name (str): The name of the prompt to retrieve.
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
version (str | None, optional): Specific prompt version to get. If None, gets highest version.
meta (dict[str, Any] | None): Optional request-level metadata.
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
ttl (int): Time to keep results available in milliseconds (default 60s).
@ -1194,10 +1223,22 @@ class Client(Generic[ClientTransportT]):
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
if task:
return await self._get_prompt_as_task(name, arguments, task_id, ttl)
# Merge version into request-level meta (not arguments)
request_meta = dict(meta) if meta else {}
if version is not None:
request_meta["fastmcp"] = {
**request_meta.get("fastmcp", {}),
"version": version,
}
result = await self.get_prompt_mcp(name=name, arguments=arguments)
if task:
return await self._get_prompt_as_task(
name, arguments, task_id, ttl, meta=request_meta or None
)
result = await self.get_prompt_mcp(
name=name, arguments=arguments, meta=request_meta or None
)
return result
async def _get_prompt_as_task(
@ -1206,6 +1247,7 @@ class Client(Generic[ClientTransportT]):
arguments: dict[str, Any] | None = None,
task_id: str | None = None,
ttl: int = 60000,
meta: dict[str, Any] | None = None,
) -> PromptTask:
"""Get a prompt for background execution (SEP-1686).
@ -1216,6 +1258,7 @@ class Client(Generic[ClientTransportT]):
arguments: Prompt arguments
task_id: Optional client-provided task ID (ignored, for backward compatibility)
ttl: Time to keep results available in milliseconds (default 60s)
meta: Optional request metadata (e.g., version info)
Returns:
PromptTask: Future-like object for accessing task status and results
@ -1238,6 +1281,7 @@ class Client(Generic[ClientTransportT]):
name=name,
arguments=serialized_arguments,
task=mcp.types.TaskMetadata(ttl=ttl),
_meta=meta, # type: ignore[unknown-argument] # pydantic alias
)
)
@ -1472,6 +1516,7 @@ class Client(Generic[ClientTransportT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
@ -1485,6 +1530,7 @@ class Client(Generic[ClientTransportT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
@ -1499,6 +1545,7 @@ class Client(Generic[ClientTransportT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
raise_on_error: bool = True,
@ -1514,6 +1561,7 @@ class Client(Generic[ClientTransportT]):
Args:
name (str): The name of the tool to call.
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
version (str | None, optional): Specific tool version to call. If None, calls highest version.
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
raise_on_error (bool, optional): Whether to raise an exception if the tool call results in an error. Defaults to True.
@ -1539,15 +1587,25 @@ class Client(Generic[ClientTransportT]):
McpError: If the tool call request results in a TimeoutError | JSONRPCError
RuntimeError: If called while the client is not connected.
"""
# Merge version into request-level meta (not arguments)
request_meta = dict(meta) if meta else {}
if version is not None:
request_meta["fastmcp"] = {
**request_meta.get("fastmcp", {}),
"version": version,
}
if task:
return await self._call_tool_as_task(name, arguments, task_id, ttl)
return await self._call_tool_as_task(
name, arguments, task_id, ttl, meta=request_meta or None
)
result = await self.call_tool_mcp(
name=name,
arguments=arguments or {},
timeout=timeout,
progress_handler=progress_handler,
meta=meta,
meta=request_meta or None,
)
return await self._parse_call_tool_result(
name, result, raise_on_error=raise_on_error
@ -1559,6 +1617,7 @@ class Client(Generic[ClientTransportT]):
arguments: dict[str, Any] | None = None,
task_id: str | None = None,
ttl: int = 60000,
meta: dict[str, Any] | None = None,
) -> ToolTask:
"""Call a tool for background execution (SEP-1686).
@ -1571,6 +1630,7 @@ class Client(Generic[ClientTransportT]):
arguments: Tool arguments
task_id: Optional client-provided task ID (ignored, for backward compatibility)
ttl: Time to keep results available in milliseconds (default 60s)
meta: Optional request metadata (e.g., version info)
Returns:
ToolTask: Future-like object for accessing task status and results
@ -1582,6 +1642,7 @@ class Client(Generic[ClientTransportT]):
name=name,
arguments=arguments or {},
task=mcp.types.TaskMetadata(ttl=ttl),
_meta=meta, # type: ignore[unknown-argument] # pydantic alias
)
)

View file

@ -24,7 +24,7 @@ from contextlib import (
from dataclasses import replace
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import anyio
import httpx
@ -103,7 +103,11 @@ from fastmcp.utilities.cli import log_server_banner
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger, temporary_log_level
from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT
from fastmcp.utilities.versions import VersionSpec, version_sort_key
from fastmcp.utilities.versions import (
VersionSpec,
parse_version_key,
version_sort_key,
)
if TYPE_CHECKING:
from docket import Docket
@ -187,6 +191,54 @@ def _get_auth_context() -> tuple[bool, Any]:
return (False, get_access_token())
C = TypeVar("C", bound="FastMCPComponent")
def _dedupe_with_versions(
components: Sequence[C],
key_fn: Callable[[C], str],
) -> list[C]:
"""Deduplicate components by key, keeping highest version.
Groups components by key, selects the highest version from each group,
and injects available versions into meta if any component is versioned.
Args:
components: Sequence of components to deduplicate.
key_fn: Function to extract the grouping key from a component.
Returns:
Deduplicated list with versions injected into meta.
"""
by_key: dict[str, list[C]] = {}
for c in components:
by_key.setdefault(key_fn(c), []).append(c)
result: list[C] = []
for versions in by_key.values():
highest: C = cast(C, max(versions, key=version_sort_key))
if any(c.version is not None for c in versions):
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 {}
highest = highest.model_copy(
update={
"meta": {
**meta,
"fastmcp": {
**meta.get("fastmcp", {}),
"versions": all_versions,
},
}
}
)
result.append(highest)
return result
@asynccontextmanager
async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
"""Default lifespan context manager that does nothing.
@ -1179,23 +1231,19 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by name (keeping highest version) and apply authorization checks
by_name: dict[str, Tool] = {}
# Filter by auth
authorized: list[Tool] = []
for tool in tools:
# Check tool-level auth (skip for STDIO)
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
try:
if not run_auth_checks(tool.auth, ctx):
continue
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
# Keep highest version per name
existing = by_name.get(tool.name)
if existing is None or version_sort_key(tool) > version_sort_key(existing):
by_name[tool.name] = tool
return list(by_name.values())
authorized.append(tool)
return _dedupe_with_versions(authorized, lambda t: t.name)
async def get_tool(
self, name: str, version: VersionSpec | str | None = None
@ -1276,26 +1324,19 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by URI (keeping highest version) and apply authorization checks
by_uri: dict[str, Resource] = {}
# Filter by auth
authorized: list[Resource] = []
for resource in resources:
# Check resource-level auth (skip for STDIO)
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
try:
if not run_auth_checks(resource.auth, ctx):
continue
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
# Keep highest version per URI
uri_str = str(resource.uri)
existing = by_uri.get(uri_str)
if existing is None or version_sort_key(resource) > version_sort_key(
existing
):
by_uri[uri_str] = resource
return list(by_uri.values())
authorized.append(resource)
return _dedupe_with_versions(authorized, lambda r: str(r.uri))
async def get_resource(
self, uri: str, version: VersionSpec | str | None = None
@ -1378,25 +1419,19 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by uri_template (keeping highest version) and apply authorization checks
by_uri_template: dict[str, ResourceTemplate] = {}
# Filter by auth
authorized: list[ResourceTemplate] = []
for template in templates:
# Check template-level auth (skip for STDIO)
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
try:
if not run_auth_checks(template.auth, ctx):
continue
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
# Keep highest version per uri_template
existing = by_uri_template.get(template.uri_template)
if existing is None or version_sort_key(template) > version_sort_key(
existing
):
by_uri_template[template.uri_template] = template
return list(by_uri_template.values())
authorized.append(template)
return _dedupe_with_versions(authorized, lambda t: t.uri_template)
async def get_resource_template(
self, uri: str, version: VersionSpec | str | None = None
@ -1485,25 +1520,19 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by name (keeping highest version) and apply authorization checks
by_name: dict[str, Prompt] = {}
# Filter by auth
authorized: list[Prompt] = []
for prompt in prompts:
# Check prompt-level auth (skip for STDIO)
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
try:
if not run_auth_checks(prompt.auth, ctx):
continue
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
# Keep highest version per name
existing = by_name.get(prompt.name)
if existing is None or version_sort_key(prompt) > version_sort_key(
existing
):
by_name[prompt.name] = prompt
return list(by_name.values())
authorized.append(prompt)
return _dedupe_with_versions(authorized, lambda p: p.name)
async def get_prompt(
self, name: str, version: VersionSpec | str | None = None
@ -1558,6 +1587,7 @@ class FastMCP(Generic[LifespanResultT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: None = None,
) -> ToolResult: ...
@ -1568,6 +1598,7 @@ class FastMCP(Generic[LifespanResultT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
@ -1577,6 +1608,7 @@ class FastMCP(Generic[LifespanResultT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> ToolResult | mcp.types.CreateTaskResult:
@ -1587,6 +1619,7 @@ class FastMCP(Generic[LifespanResultT]):
Args:
name: The tool name
arguments: Tool arguments (optional)
version: Specific version to call. If None, calls highest version.
run_middleware: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
task_meta: If provided, execute as a background task and return
@ -1622,6 +1655,7 @@ class FastMCP(Generic[LifespanResultT]):
call_next=lambda context: self.call_tool(
context.message.name,
context.message.arguments or {},
version=version,
run_middleware=False,
task_meta=task_meta,
),
@ -1631,7 +1665,7 @@ class FastMCP(Generic[LifespanResultT]):
with server_span(
f"tools/call {name}", "tools/call", self.name, "tool", name
) as span:
tool = await self.get_tool(name)
tool = await self.get_tool(name, version=version)
span.set_attributes(tool.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=tool.key)
@ -1654,6 +1688,7 @@ class FastMCP(Generic[LifespanResultT]):
self,
uri: str,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: None = None,
) -> ResourceResult: ...
@ -1663,6 +1698,7 @@ class FastMCP(Generic[LifespanResultT]):
self,
uri: str,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
@ -1671,6 +1707,7 @@ class FastMCP(Generic[LifespanResultT]):
self,
uri: str,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> ResourceResult | mcp.types.CreateTaskResult:
@ -1681,6 +1718,7 @@ class FastMCP(Generic[LifespanResultT]):
Args:
uri: The resource URI
version: Specific version to read. If None, reads highest version.
run_middleware: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
task_meta: If provided, execute as a background task and return
@ -1716,6 +1754,7 @@ class FastMCP(Generic[LifespanResultT]):
context=mw_context,
call_next=lambda context: self.read_resource(
str(context.message.uri),
version=version,
run_middleware=False,
task_meta=task_meta,
),
@ -1732,7 +1771,7 @@ class FastMCP(Generic[LifespanResultT]):
) as span:
# Try concrete resources first
try:
resource = await self.get_resource(uri)
resource = await self.get_resource(uri, version=version)
span.set_attributes(resource.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=resource.key)
@ -1750,9 +1789,13 @@ class FastMCP(Generic[LifespanResultT]):
# Try templates
try:
template = await self.get_resource_template(uri)
template = await self.get_resource_template(uri, version=version)
except NotFoundError:
raise NotFoundError(f"Unknown resource: {uri!r}") from None
if version is None:
raise NotFoundError(f"Unknown resource: {uri!r}") from None
raise NotFoundError(
f"Unknown resource: {uri!r} version {version!r}"
) from None
span.set_attributes(template.get_span_attributes())
params = template.matches(uri)
assert params is not None
@ -1775,6 +1818,7 @@ class FastMCP(Generic[LifespanResultT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: None = None,
) -> PromptResult: ...
@ -1785,6 +1829,7 @@ class FastMCP(Generic[LifespanResultT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: TaskMeta,
) -> mcp.types.CreateTaskResult: ...
@ -1794,6 +1839,7 @@ class FastMCP(Generic[LifespanResultT]):
name: str,
arguments: dict[str, Any] | None = None,
*,
version: str | None = None,
run_middleware: bool = True,
task_meta: TaskMeta | None = None,
) -> PromptResult | mcp.types.CreateTaskResult:
@ -1805,6 +1851,7 @@ class FastMCP(Generic[LifespanResultT]):
Args:
name: The prompt name
arguments: Prompt arguments (optional)
version: Specific version to render. If None, renders highest version.
run_middleware: If True (default), apply the middleware chain.
Set to False when called from middleware to avoid re-applying.
task_meta: If provided, execute as a background task and return
@ -1835,6 +1882,7 @@ class FastMCP(Generic[LifespanResultT]):
call_next=lambda context: self.render_prompt(
context.message.name,
context.message.arguments,
version=version,
run_middleware=False,
task_meta=task_meta,
),
@ -1844,7 +1892,7 @@ class FastMCP(Generic[LifespanResultT]):
with server_span(
f"prompts/get {name}", "prompts/get", self.name, "prompt", name
) as span:
prompt = await self.get_prompt(name)
prompt = await self.get_prompt(name, version=version)
span.set_attributes(prompt.get_span_attributes())
if task_meta is not None and task_meta.fn_key is None:
task_meta = replace(task_meta, fn_key=prompt.key)
@ -2011,11 +2059,17 @@ class FastMCP(Generic[LifespanResultT]):
)
try:
# Extract SEP-1686 task metadata from request context.
# Extract version and task metadata from request context.
# fn_key is set by call_tool() after finding the tool.
version: str | None = None
task_meta: TaskMeta | None = None
try:
ctx = self._mcp_server.request_context
# Extract version from request-level _meta.fastmcp.version
if ctx.meta:
meta_dict = ctx.meta.model_dump(exclude_none=True)
version = meta_dict.get("fastmcp", {}).get("version")
# Extract SEP-1686 task metadata
if ctx.experimental.is_task:
mcp_task_meta = ctx.experimental.task_metadata
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
@ -2023,7 +2077,9 @@ class FastMCP(Generic[LifespanResultT]):
except (AttributeError, LookupError):
pass
result = await self.call_tool(key, arguments, task_meta=task_meta)
result = await self.call_tool(
key, arguments, version=version, task_meta=task_meta
)
if isinstance(result, mcp.types.CreateTaskResult):
return result
@ -2052,11 +2108,17 @@ class FastMCP(Generic[LifespanResultT]):
logger.debug(f"[{self.name}] Handler called: read_resource %s", uri)
try:
# Extract SEP-1686 task metadata from request context.
# fn_key is set by read_resource() after finding the resource/template.
# Extract version and task metadata from request context.
version: str | None = None
task_meta: TaskMeta | None = None
try:
ctx = self._mcp_server.request_context
# Extract version from _meta.fastmcp.version if provided
if ctx.meta:
meta_dict = ctx.meta.model_dump(exclude_none=True)
fastmcp_meta = meta_dict.get("fastmcp") or {}
version = fastmcp_meta.get("version")
# Extract SEP-1686 task metadata
if ctx.experimental.is_task:
mcp_task_meta = ctx.experimental.task_metadata
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
@ -2064,7 +2126,9 @@ class FastMCP(Generic[LifespanResultT]):
except (AttributeError, LookupError):
pass
result = await self.read_resource(str(uri), task_meta=task_meta)
result = await self.read_resource(
str(uri), version=version, task_meta=task_meta
)
if isinstance(result, mcp.types.CreateTaskResult):
return result
@ -2095,11 +2159,17 @@ class FastMCP(Generic[LifespanResultT]):
)
try:
# Extract SEP-1686 task metadata from request context.
# Extract version and task metadata from request context.
# fn_key is set by render_prompt() after finding the prompt.
version: str | None = None
task_meta: TaskMeta | None = None
try:
ctx = self._mcp_server.request_context
# Extract version from request-level _meta.fastmcp.version
if ctx.meta:
meta_dict = ctx.meta.model_dump(exclude_none=True)
version = meta_dict.get("fastmcp", {}).get("version")
# Extract SEP-1686 task metadata
if ctx.experimental.is_task:
mcp_task_meta = ctx.experimental.task_metadata
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
@ -2107,7 +2177,9 @@ class FastMCP(Generic[LifespanResultT]):
except (AttributeError, LookupError):
pass
result = await self.render_prompt(name, arguments, task_meta=task_meta)
result = await self.render_prompt(
name, arguments, version=version, task_meta=task_meta
)
if isinstance(result, mcp.types.CreateTaskResult):
return result

View file

@ -988,3 +988,266 @@ class TestVersionValidation:
@mcp.tool(version="1.0@beta")
def my_tool() -> str:
return "test"
class TestVersionMetadata:
"""Tests for version metadata exposure in list operations."""
async def test_tool_versions_in_meta(self):
"""List tools should include versions list in meta."""
mcp = FastMCP()
@mcp.tool(version="1.0")
def add(x: int, y: int) -> int: # noqa: F811
return x + y
@mcp.tool(version="2.0")
def add(x: int, y: int) -> int: # noqa: F811
return x + y
tools = await mcp.get_tools()
assert len(tools) == 1
tool = tools[0]
meta = tool.get_meta()
assert meta["fastmcp"]["version"] == "2.0"
assert meta["fastmcp"]["versions"] == ["2.0", "1.0"]
async def test_resource_versions_in_meta(self):
"""List resources should include versions list in meta."""
mcp = FastMCP()
@mcp.resource("data://config", version="1.0")
def config_v1() -> str: # noqa: F811
return "v1"
@mcp.resource("data://config", version="2.0")
def config_v2() -> str: # noqa: F811
return "v2"
resources = await mcp.get_resources()
assert len(resources) == 1
resource = resources[0]
meta = resource.get_meta()
assert meta["fastmcp"]["version"] == "2.0"
assert meta["fastmcp"]["versions"] == ["2.0", "1.0"]
async def test_prompt_versions_in_meta(self):
"""List prompts should include versions list in meta."""
mcp = FastMCP()
@mcp.prompt(version="1.0")
def greet() -> str: # noqa: F811
return "Hello v1"
@mcp.prompt(version="2.0")
def greet() -> str: # noqa: F811
return "Hello v2"
prompts = await mcp.get_prompts()
assert len(prompts) == 1
prompt = prompts[0]
meta = prompt.get_meta()
assert meta["fastmcp"]["version"] == "2.0"
assert meta["fastmcp"]["versions"] == ["2.0", "1.0"]
async def test_unversioned_no_versions_list(self):
"""Unversioned components should not have versions list in meta."""
mcp = FastMCP()
@mcp.tool
def simple() -> str:
return "simple"
tools = await mcp.get_tools()
assert len(tools) == 1
tool = tools[0]
meta = tool.get_meta()
assert "versions" not in meta.get("fastmcp", {})
class TestVersionedCalls:
"""Tests for calling specific component versions."""
async def test_call_tool_with_version(self):
"""call_tool should use specified version."""
mcp = FastMCP()
@mcp.tool(version="1.0")
def calculate(x: int, y: int) -> int: # noqa: F811
return x + y
@mcp.tool(version="2.0")
def calculate(x: int, y: int) -> int: # noqa: F811
return x * y
# Default: highest version (2.0, multiplication)
result = await mcp.call_tool("calculate", {"x": 3, "y": 4})
assert result.structured_content is not None
assert result.structured_content["result"] == 12
# Explicit v1.0 (addition)
result = await mcp.call_tool("calculate", {"x": 3, "y": 4}, version="1.0")
assert result.structured_content is not None
assert result.structured_content["result"] == 7
# Explicit v2.0 (multiplication)
result = await mcp.call_tool("calculate", {"x": 3, "y": 4}, version="2.0")
assert result.structured_content is not None
assert result.structured_content["result"] == 12
async def test_read_resource_with_version(self):
"""read_resource should use specified version."""
mcp = FastMCP()
@mcp.resource("data://config", version="1.0")
def config() -> str: # noqa: F811
return "config v1"
@mcp.resource("data://config", version="2.0")
def config() -> str: # noqa: F811
return "config v2"
# Default: highest version
result = await mcp.read_resource("data://config")
assert result.contents[0].content == "config v2"
# Explicit v1.0
result = await mcp.read_resource("data://config", version="1.0")
assert result.contents[0].content == "config v1"
async def test_render_prompt_with_version(self):
"""render_prompt should use specified version."""
mcp = FastMCP()
@mcp.prompt(version="1.0")
def greet() -> str: # noqa: F811
return "Hello from v1"
@mcp.prompt(version="2.0")
def greet() -> str: # noqa: F811
return "Hello from v2"
# Default: highest version
result = await mcp.render_prompt("greet")
content = result.messages[0].content
assert isinstance(content, TextContent) and content.text == "Hello from v2"
# Explicit v1.0
result = await mcp.render_prompt("greet", version="1.0")
content = result.messages[0].content
assert isinstance(content, TextContent) and content.text == "Hello from v1"
async def test_call_tool_invalid_version_not_found(self):
"""Calling with non-existent version should raise NotFoundError."""
import pytest
from fastmcp.exceptions import NotFoundError
mcp = FastMCP()
@mcp.tool(version="1.0")
def mytool() -> str:
return "v1"
with pytest.raises(NotFoundError):
await mcp.call_tool("mytool", {}, version="999.0")
class TestClientVersionSelection:
"""Tests for client-side version selection via the version parameter.
Version selection flows through request-level _meta, not arguments.
"""
import pytest
@pytest.mark.parametrize(
"version,expected",
[
(None, 10), # Default: highest version (2.0) -> 5 * 2
("1.0", 6), # v1.0 -> 5 + 1
("2.0", 10), # v2.0 -> 5 * 2
],
)
async def test_call_tool_version_selection(
self, version: str | None, expected: int
):
"""Client.call_tool routes to correct version via request meta."""
from fastmcp import Client
mcp = FastMCP()
@mcp.tool(version="1.0")
def calc(x: int) -> int: # noqa: F811
return x + 1
@mcp.tool(version="2.0")
def calc(x: int) -> int: # noqa: F811
return x * 2
async with Client(mcp) as client:
result = await client.call_tool("calc", {"x": 5}, version=version)
assert result.data == expected
@pytest.mark.parametrize(
"version,expected",
[
(None, "Hello world from v2"), # Default: highest version
("1.0", "Hello world from v1"),
("2.0", "Hello world from v2"),
],
)
async def test_get_prompt_version_selection(
self, version: str | None, expected: str
):
"""Client.get_prompt routes to correct version via request meta."""
from fastmcp import Client
mcp = FastMCP()
@mcp.prompt(version="1.0")
def greet(name: str) -> str: # noqa: F811
return f"Hello {name} from v1"
@mcp.prompt(version="2.0")
def greet(name: str) -> str: # noqa: F811
return f"Hello {name} from v2"
async with Client(mcp) as client:
result = await client.get_prompt(
"greet", {"name": "world"}, version=version
)
content = result.messages[0].content
assert isinstance(content, TextContent) and content.text == expected
@pytest.mark.parametrize(
"version,expected",
[
(None, "v2 data"), # Default: highest version
("1.0", "v1 data"),
("2.0", "v2 data"),
],
)
async def test_read_resource_version_selection(
self, version: str | None, expected: str
):
"""Client.read_resource routes to correct version via request meta."""
from fastmcp import Client
mcp = FastMCP()
@mcp.resource("data://info", version="1.0")
def info_v1() -> str: # noqa: F811
return "v1 data"
@mcp.resource("data://info", version="2.0")
def info_v2() -> str: # noqa: F811
return "v2 data"
async with Client(mcp) as client:
result = await client.read_resource("data://info", version=version)
assert result[0].text == expected # type: ignore[union-attr]