diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 89a225c3fe..1cbb9cbd1b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -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 diff --git a/docs/clients/prompts.mdx b/docs/clients/prompts.mdx index c4ae2f4d4d..84dcdd9f5d 100644 --- a/docs/clients/prompts.mdx +++ b/docs/clients/prompts.mdx @@ -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 + + + +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 @@ -213,4 +230,48 @@ async with client: 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. - \ No newline at end of file + + +## Version Selection + + + +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. + + +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. + \ No newline at end of file diff --git a/docs/clients/resources.mdx b/docs/clients/resources.mdx index cef312267a..865d669cc9 100644 --- a/docs/clients/resources.mdx +++ b/docs/clients/resources.mdx @@ -81,9 +81,35 @@ async with client: ``` -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. +### Version Information + + + +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 diff --git a/docs/clients/tools.mdx b/docs/clients/tools.mdx index 17bc9f6924..a01e2dd662 100644 --- a/docs/clients/tools.mdx +++ b/docs/clients/tools.mdx @@ -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: 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). - \ No newline at end of file + + +## Version Selection + + + +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. + + +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. + \ No newline at end of file diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 8e3d9e0da9..4c0e0db293 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -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:** diff --git a/docs/servers/versioning.mdx b/docs/servers/versioning.mdx index 1079249b41..f4db7584dc 100644 --- a/docs/servers/versioning.mdx +++ b/docs/servers/versioning.mdx @@ -157,9 +157,83 @@ def summarize(text: str, style: str = "concise") -> str: return f"Summarize in a {style} style: {text}" ``` - -Clients always receive the highest version of each component. Versioning controls what the server exposes, not what clients request. - +### 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. + + +```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" + } + } +} +``` + + +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 diff --git a/examples/versioning/client_version_selection.py b/examples/versioning/client_version_selection.py new file mode 100644 index 0000000000..0d5519aa3b --- /dev/null +++ b/examples/versioning/client_version_selection.py @@ -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()) diff --git a/examples/versioning/version_filters.py b/examples/versioning/version_filters.py new file mode 100644 index 0000000000..896164daa2 --- /dev/null +++ b/examples/versioning/version_filters.py @@ -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()) diff --git a/examples/versioning/versioned_components.py b/examples/versioning/versioned_components.py new file mode 100644 index 0000000000..5f53e3c7fb --- /dev/null +++ b/examples/versioning/versioned_components.py @@ -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()) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 35ecde7bc0..e7a287112e 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -902,7 +902,7 @@ async def read_resource_mcp( 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 @@ async def read_resource( 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 @@ async def read_resource( 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 @@ async def read_resource( 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 @@ async def read_resource( 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 @@ async def read_resource( 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 @@ async def read_resource( 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 @@ async def _read_resource_as_task( 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 @@ async def _read_resource_as_task( 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 @@ async def _read_resource_as_task( params=mcp.types.ReadResourceRequestParams( uri=uri, task=mcp.types.TaskMetadata(ttl=ttl), + _meta=meta, # type: ignore[unknown-argument] # pydantic alias ) ) @@ -1133,7 +1154,7 @@ async def get_prompt_mcp( 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 @@ async def get_prompt( 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 @@ async def get_prompt( 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 @@ async def get_prompt( 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 @@ async def get_prompt( 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 @@ async def get_prompt( 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._get_prompt_as_task(name, arguments, task_id, ttl) + 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) + 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 @@ async def _get_prompt_as_task( 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 @@ async def _get_prompt_as_task( 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 @@ async def _get_prompt_as_task( name=name, arguments=serialized_arguments, task=mcp.types.TaskMetadata(ttl=ttl), + _meta=meta, # type: ignore[unknown-argument] # pydantic alias ) ) @@ -1472,6 +1516,7 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def _call_tool_as_task( 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 @@ async def _call_tool_as_task( 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 @@ async def _call_tool_as_task( name=name, arguments=arguments or {}, task=mcp.types.TaskMetadata(ttl=ttl), + _meta=meta, # type: ignore[unknown-argument] # pydantic alias ) ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 3ca76f4591..5fa10c11d4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -24,7 +24,7 @@ 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.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 @@ async def get_tools(self, *, run_middleware: bool = False) -> list[Tool]: # 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 @@ async def get_resources(self, *, run_middleware: bool = False) -> list[Resource] # 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 @@ async def get_resource_templates( # 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 @@ async def get_prompts(self, *, run_middleware: bool = False) -> list[Prompt]: # 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def call_tool( 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 @@ async def read_resource( self, uri: str, *, + version: str | None = None, run_middleware: bool = True, task_meta: None = None, ) -> ResourceResult: ... @@ -1663,6 +1698,7 @@ async def read_resource( self, uri: str, *, + version: str | None = None, run_middleware: bool = True, task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... @@ -1671,6 +1707,7 @@ async def read_resource( self, uri: str, *, + version: str | None = None, run_middleware: bool = True, task_meta: TaskMeta | None = None, ) -> ResourceResult | mcp.types.CreateTaskResult: @@ -1681,6 +1718,7 @@ async def read_resource( 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 @@ async def read_resource( 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 @@ async def read_resource( ) 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 @@ async def read_resource( # 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 @@ async def render_prompt( 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 @@ async def render_prompt( 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 @@ async def render_prompt( 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 @@ async def render_prompt( 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 @@ async def render_prompt( 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 @@ async def render_prompt( 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 @@ async def _call_tool_mcp( ) 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 @@ async def _call_tool_mcp( 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 @@ async def _read_resource_mcp( 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 @@ async def _read_resource_mcp( 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 @@ async def _get_prompt_mcp( ) 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 @@ async def _get_prompt_mcp( 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 diff --git a/tests/server/test_versioning.py b/tests/server/test_versioning.py index 7742ae485a..a7bf344616 100644 --- a/tests/server/test_versioning.py +++ b/tests/server/test_versioning.py @@ -988,3 +988,266 @@ async def test_version_with_at_symbol_rejected(self): @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]