diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx
index 18af5b7e4..c5465f1b3 100644
--- a/docs/development/upgrade-guide.mdx
+++ b/docs/development/upgrade-guide.mdx
@@ -133,8 +133,8 @@ tool.enable()
```
```python After
-server.disable(keys=["tool:my_tool"])
-server.enable(keys=["tool:my_tool"])
+server.disable(keys=["tool:my_tool@"])
+server.enable(keys=["tool:my_tool@"])
```
diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx
index efd5e7deb..8e3d9e0da 100644
--- a/docs/development/v3-notes/v3-features.mdx
+++ b/docs/development/v3-notes/v3-features.mdx
@@ -107,6 +107,7 @@ Transforms modify components (tools, resources, prompts) as they flow from provi
- `Namespace` - adds prefixes to names (`tool` → `api_tool`) and path segments to URIs (`data://x` → `data://api/x`)
- `ToolTransform` - modifies tool schemas (rename, description, tags, argument transforms)
- `Visibility` - filters components by key or tag (backs `enable()`/`disable()` API)
+- `VersionFilter` - filters components by version range (`version_gte`, `version_lt`)
```python
from fastmcp.server.transforms import Namespace, ToolTransform
@@ -188,14 +189,14 @@ Components can be dynamically enabled/disabled at runtime using the visibility s
```python
mcp = FastMCP("Server")
-# Disable specific components
-mcp.disable(keys=["tool:dangerous_tool"])
+# Disable specific components (keys include @ version suffix)
+mcp.disable(keys=["tool:dangerous_tool@"])
# Disable by tag
mcp.disable(tags={"admin"})
# Allowlist mode - only show these
-mcp.enable(keys=["tool:safe_tool"], only=True)
+mcp.enable(keys=["tool:safe_tool@"], only=True)
```
Works at both server and provider level. Supports:
@@ -205,6 +206,86 @@ Works at both server and provider level. Supports:
---
+## Component Versioning
+
+v3.0 introduces versioning support for tools, resources, and prompts. Components can declare a version, and when multiple versions of the same component exist, the highest version is automatically exposed to clients.
+
+**Declaring versions:**
+
+```python
+@mcp.tool(version="1.0")
+def add(x: int, y: int) -> int:
+ return x + y
+
+@mcp.tool(version="2.0")
+def add(x: int, y: int, z: int = 0) -> int:
+ return x + y + z
+
+# Only v2.0 is exposed to clients via list_tools()
+# Calling "add" invokes the v2.0 implementation
+```
+
+**Version comparison:**
+- Uses PEP 440 semantic versioning (1.10 > 1.9 > 1.2)
+- Falls back to string comparison for non-PEP 440 versions (dates like `2025-01-15` work)
+- Unversioned components sort lower than any versioned component
+- The `v` prefix is normalized (`v1.0` equals `1.0`)
+
+**Retrieving specific versions:**
+
+```python
+# Get the highest version (default)
+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")
+```
+
+**VersionFilter transform:**
+
+The `VersionFilter` transform enables serving different API versions from a single codebase:
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.providers import LocalProvider
+from fastmcp.server.transforms import VersionFilter
+
+# Define components on a shared provider
+components = LocalProvider()
+
+@components.tool(version="1.0")
+def calculate(x: int, y: int) -> int:
+ return x + y
+
+@components.tool(version="2.0")
+def calculate(x: int, y: int, z: int = 0) -> int:
+ return x + y + z
+
+# Create servers that share the provider with different filters
+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"))
+```
+
+Parameters mirror comparison operators:
+- `version_gte`: Versions >= this value pass through
+- `version_lt`: Versions < this value pass through
+
+**Key format:**
+
+Component keys now include a version suffix using `@` as a delimiter:
+- Versioned: `tool:add@1.0`, `resource:data://config@2.0`
+- Unversioned: `tool:add@`, `resource:data://config@`
+
+The `@` is always present (even for unversioned components) to enable unambiguous parsing of URIs that may contain `@`.
+
+---
+
## Type-Safe Canonical Results
v3.0 introduces type-safe result classes that provide explicit control over component responses while supporting MCP runtime metadata: `ToolResult` ([#2736](https://github.com/jlowin/fastmcp/pull/2736)), `ResourceResult` ([#2734](https://github.com/jlowin/fastmcp/pull/2734)), and `PromptResult` ([#2738](https://github.com/jlowin/fastmcp/pull/2738)).
@@ -695,7 +776,7 @@ tool = await server.get_tool("my_tool")
tool.disable()
# v3.0
-server.disable(keys=["tool:my_tool"])
+server.disable(keys=["tool:my_tool@"])
```
### Component Lookup Methods
diff --git a/docs/docs.json b/docs/docs.json
index 671c1a9d7..5b1c59f23 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -129,6 +129,7 @@
"servers/storage-backends",
"servers/tasks",
"servers/telemetry",
+ "servers/versioning",
"servers/visibility"
]
},
diff --git a/docs/servers/prompts.mdx b/docs/servers/prompts.mdx
index 1ec521e90..fb024affc 100644
--- a/docs/servers/prompts.mdx
+++ b/docs/servers/prompts.mdx
@@ -111,6 +111,12 @@ def data_analysis_prompt(
Optional meta information about the prompt. This data is passed through to the MCP client as the `meta` field of the client-side prompt object and can be used for custom metadata, versioning, or other application-specific purposes.
+
+
+
+
+ Optional version identifier for this prompt. See [Versioning](/servers/versioning) for details.
+
#### Using with Methods
@@ -444,4 +450,10 @@ The duplicate behavior options are:
- `"warn"` (default): Logs a warning, and the new prompt replaces the old one.
- `"error"`: Raises a `ValueError`, preventing the duplicate registration.
- `"replace"`: Silently replaces the existing prompt with the new one.
-- `"ignore"`: Keeps the original prompt and ignores the new registration attempt.
+- `"ignore"`: Keeps the original prompt and ignores the new registration attempt.
+
+## Versioning
+
+
+
+Prompts support versioning, allowing you to maintain multiple implementations under the same name while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns.
diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx
index a0157a68f..5a682f16d 100644
--- a/docs/servers/resources.mdx
+++ b/docs/servers/resources.mdx
@@ -127,9 +127,15 @@ def get_application_status() -> str:
-
+
Optional meta information about the resource. This data is passed through to the MCP client as the `meta` field of the client-side resource object and can be used for custom metadata, versioning, or other application-specific purposes.
+
+
+
+
+ Optional version identifier for this resource. See [Versioning](/servers/versioning) for details.
+
#### Using with Methods
@@ -730,4 +736,10 @@ The duplicate behavior options are:
- `"warn"` (default): Logs a warning, and the new resource/template replaces the old one.
- `"error"`: Raises a `ValueError`, preventing the duplicate registration.
- `"replace"`: Silently replaces the existing resource/template with the new one.
-- `"ignore"`: Keeps the original resource/template and ignores the new registration attempt.
\ No newline at end of file
+- `"ignore"`: Keeps the original resource/template and ignores the new registration attempt.
+
+## Versioning
+
+
+
+Resources and resource templates support versioning, allowing you to maintain multiple implementations under the same URI while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns.
\ No newline at end of file
diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx
index d9ed6521d..770d1db5a 100644
--- a/docs/servers/tools.mdx
+++ b/docs/servers/tools.mdx
@@ -120,6 +120,12 @@ def search_products_implementation(query: str, category: str | None = None) -> l
Execution timeout in seconds. If the tool takes longer than this to complete, an MCP error is returned to the client. See [Timeouts](#timeouts) for details.
+
+
+
+
+ Optional version identifier for this tool. See [Versioning](/servers/versioning) for details.
+
### Using with Methods
@@ -1062,3 +1068,9 @@ def calculate_sum(a: int, b: int) -> int:
mcp.remove_tool("calculate_sum")
```
+
+## Versioning
+
+
+
+Tools support versioning, allowing you to maintain multiple implementations under the same name while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns.
diff --git a/docs/servers/versioning.mdx b/docs/servers/versioning.mdx
new file mode 100644
index 000000000..1079249b4
--- /dev/null
+++ b/docs/servers/versioning.mdx
@@ -0,0 +1,259 @@
+---
+title: Versioning
+sidebarTitle: Versioning
+description: Serve multiple API versions from a single codebase
+icon: code-branch
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+Component versioning lets you maintain multiple implementations of the same tool, resource, or prompt under a single identifier. You register each version, and FastMCP handles the rest: clients see the highest version by default, but you can filter to expose exactly the versions you want.
+
+The primary use case is serving different API versions from one codebase. Instead of maintaining separate deployments for v1 and v2 clients, you version your components and use `VersionFilter` to create distinct API surfaces.
+
+## Versioned API Surfaces
+
+Consider a server that needs to support both v1 and v2 clients. The v2 API adds new parameters to existing tools, and you want both versions to coexist cleanly. You define your components on a shared provider, then create separate servers with different version filters.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.providers import LocalProvider
+from fastmcp.server.transforms import VersionFilter
+
+# Define versioned components on a shared provider
+components = LocalProvider()
+
+@components.tool(version="1.0")
+def calculate(x: int, y: int) -> int:
+ """Add two numbers."""
+ return x + y
+
+@components.tool(version="2.0")
+def calculate(x: int, y: int, z: int = 0) -> int:
+ """Add two or three numbers."""
+ return x + y + z
+
+# Create servers that share the provider with different filters
+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"))
+```
+
+Clients connecting to `api_v1` see the two-argument `calculate`. Clients connecting to `api_v2` see the three-argument version. Both servers share the same component definitions.
+
+`VersionFilter` accepts two keyword-only parameters that mirror comparison operators: `version_gte` (greater than or equal) and `version_lt` (less than). You can use either or both to define your version range.
+
+```python
+# Versions < 3.0 (v1.x and v2.x)
+VersionFilter(version_lt="3.0")
+
+# Versions >= 2.0 (v2.x and later)
+VersionFilter(version_gte="2.0")
+
+# Versions in range [2.0, 3.0) (only v2.x)
+VersionFilter(version_gte="2.0", version_lt="3.0")
+```
+
+
+**Unversioned components are exempt from version filtering.** A `VersionFilter` only affects versioned components—unversioned components always pass through regardless of the filter's constraints. This ensures that adding version filtering to a server with mixed versioned and unversioned tools doesn't accidentally hide the unversioned ones. To prevent confusion, FastMCP forbids mixing versioned and unversioned components with the same name.
+
+
+### Filtering Mounted Servers
+
+When you mount child servers and apply a `VersionFilter` to the parent, the filter applies to components from mounted servers as well. Range filtering (`version_gte` and `version_lt`) is handled at the provider level, meaning mounted servers don't need to know about the parent's version constraints.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.transforms import VersionFilter
+
+# Child server with versioned components
+child = FastMCP("Child")
+
+@child.tool(version="1.0")
+def process(data: str) -> str:
+ return data.upper()
+
+@child.tool(version="2.0")
+def process(data: str, mode: str = "default") -> str:
+ return data.upper() if mode == "default" else data.lower()
+
+# Parent server mounts child and applies version filter
+parent = FastMCP("Parent")
+parent.mount(child, namespace="child")
+parent.add_transform(VersionFilter(version_lt="2.0"))
+
+# Clients see only child_process v1.0
+```
+
+The parent's `VersionFilter` sees components after they've been namespaced, but filters based on version regardless of namespace. This lets you apply version policies consistently across your entire server hierarchy.
+
+## Declaring Versions
+
+Add a `version` parameter to any component decorator. FastMCP stores versions as strings and groups components by their identifier (name for tools and prompts, URI for resources).
+
+```python
+from fastmcp import FastMCP
+
+mcp = FastMCP()
+
+@mcp.tool(version="1.0")
+def process(data: str) -> str:
+ """Original processing."""
+ return data.upper()
+
+@mcp.tool(version="2.0")
+def process(data: str, mode: str = "default") -> str:
+ """Enhanced processing with mode selection."""
+ if mode == "reverse":
+ return data[::-1].upper()
+ return data.upper()
+```
+
+Both versions are registered. When a client lists tools, they see only `process` with version 2.0 (the highest). When they invoke `process`, version 2.0 executes. The same pattern applies to resources and prompts.
+
+### Versioned vs Unversioned Components
+
+For any given component name, you must choose one approach: either version all implementations or version none of them. Mixing versioned and unversioned components with the same name raises an error at registration time.
+
+```python
+from fastmcp import FastMCP
+
+mcp = FastMCP()
+
+@mcp.tool
+def calculate(x: int, y: int) -> int:
+ """Unversioned tool."""
+ return x + y
+
+@mcp.tool(version="2.0") # Raises ValueError
+def calculate(x: int, y: int, z: int = 0) -> int:
+ """Cannot mix versioned with unversioned."""
+ return x + y + z
+```
+
+The error message explains the conflict: "Cannot add versioned tool 'calculate' (version='2.0'): an unversioned tool with this name already exists. Either version all components or none."
+
+This restriction exists because unversioned components always pass through version filters. If you could mix versioned and unversioned components, you'd have no way to filter out the unversioned one using `VersionFilter`. By enforcing consistency at registration, FastMCP ensures version filtering behaves predictably.
+
+```python
+@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", "schema": "v2"}'
+
+@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:
+ 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 Comparison
+
+FastMCP compares versions to determine which is "highest" when multiple versions share an identifier. The comparison behavior depends on the version format.
+
+For [PEP 440](https://peps.python.org/pep-0440/) versions (like `"1.0"`, `"2.1.3"`, `"1.0a1"`), FastMCP uses semantic comparison where numeric segments are compared as numbers.
+
+```python
+# PEP 440 versions compare semantically
+"1" < "2" < "10" # Not string order ("1" < "10" < "2")
+"1.9" < "1.10" # Not string order ("1.10" < "1.9")
+"1.0a1" < "1.0b1" < "1.0" # Pre-releases sort before releases
+```
+
+For other formats (dates, custom schemes), FastMCP falls back to lexicographic string comparison. This works well for ISO dates and other naturally sortable formats.
+
+```python
+# Non-PEP 440 versions compare as strings
+"2025-01-15" < "2025-02-01" # ISO dates sort correctly
+"alpha" < "beta" # Alphabetical order
+```
+
+The `v` prefix is stripped before comparison, so `"v1.0"` and `"1.0"` are treated as equal for sorting purposes.
+
+## Retrieving Specific Versions
+
+Server-side code can retrieve specific versions rather than just the highest. This is useful during migrations when you need to compare behavior between versions or access legacy implementations.
+
+The `get_tool`, `get_resource`, and `get_prompt` methods accept an optional `version` parameter. Without it, they return the highest version. With it, they return exactly that version.
+
+```python
+from fastmcp import FastMCP
+
+mcp = FastMCP()
+
+@mcp.tool(version="1.0")
+def add(x: int, y: int) -> int:
+ return x + y
+
+@mcp.tool(version="2.0")
+def add(x: int, y: int) -> int:
+ return x + y + 100 # Different behavior
+
+# Get highest version (default)
+tool = await mcp.get_tool("add")
+print(tool.version) # "2.0"
+
+# Get specific version
+tool_v1 = await mcp.get_tool("add", version="1.0")
+print(tool_v1.version) # "1.0"
+```
+
+If the requested version doesn't exist, a `NotFoundError` is raised.
+
+## Removing Versions
+
+The `remove_tool`, `remove_resource`, and `remove_prompt` methods accept an optional `version` parameter that controls what gets removed.
+
+```python
+# Remove ALL versions of a component
+mcp.remove_tool("calculate")
+
+# Remove only a specific version
+mcp.remove_tool("calculate", version="1.0")
+```
+
+When you remove a specific version, other versions remain registered. When you remove without specifying a version, all versions are removed.
+
+## Migration Workflow
+
+Versioning supports gradual migration when updating component behavior. You can deploy new versions alongside old ones, verify the new behavior works correctly, then clean up.
+
+When migrating an existing unversioned component to use versioning, start by assigning an initial version to your existing implementation. Then add the new version alongside it.
+
+```python
+from fastmcp import FastMCP
+
+mcp = FastMCP()
+
+@mcp.tool(version="1.0")
+def process_data(input: str) -> str:
+ """Original implementation, now versioned."""
+ return legacy_process(input)
+
+@mcp.tool(version="2.0")
+def process_data(input: str, options: dict | None = None) -> str:
+ """Updated implementation with new options parameter."""
+ return modern_process(input, options or {})
+```
+
+Clients automatically see version 2.0 (the highest). During the transition, your server code can still access the original implementation via `get_tool("process_data", version="1.0")`.
+
+Once the migration is complete, remove the old version.
+
+```python
+mcp.remove_tool("process_data", version="1.0")
+```
diff --git a/pyproject.toml b/pyproject.toml
index 62ae05c82..a2951b1e8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ dependencies = [
"mcp>=1.24.0,<2.0",
"openapi-pydantic>=0.5.1",
"opentelemetry-api>=1.20.0",
+ "packaging>=24.0",
"platformdirs>=4.0.0",
"rich>=13.9.4",
"cyclopts>=4.0.0",
diff --git a/src/fastmcp/contrib/component_manager/component_service.py b/src/fastmcp/contrib/component_manager/component_service.py
index 5ef9b332c..105994464 100644
--- a/src/fastmcp/contrib/component_manager/component_service.py
+++ b/src/fastmcp/contrib/component_manager/component_service.py
@@ -94,13 +94,20 @@ class ComponentService:
name: The name of the tool to enable
Returns:
- The tool that was enabled
+ The tool that was enabled (highest version)
"""
logger.debug("Enabling tool: %s", name)
- # 1. Check local tools first. The server will have already applied its filter.
- if Tool.make_key(name) in self._server._local_provider._components:
- self._server.enable(keys=[Tool.make_key(name)])
+ # 1. Check local tools first - find ALL versions of this tool
+ # Keys are "tool:name@" (unversioned) or "tool:name@version" (versioned)
+ key_prefix = f"{Tool.make_key(name)}@"
+ matching_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == key_prefix or k.startswith(key_prefix)
+ ]
+ if matching_keys:
+ self._server.enable(keys=matching_keys)
tool = await self._server.get_tool(name)
if tool is None:
raise NotFoundError(f"Unknown tool: {name!r}")
@@ -123,17 +130,24 @@ class ComponentService:
name: The name of the tool to disable
Returns:
- The tool that was disabled
+ The tool that was disabled (highest version)
"""
logger.debug("Disabling tool: %s", name)
- # 1. Check local tools first. The server will have already applied its filter.
- key = Tool.make_key(name)
- if key in self._server._local_provider._components:
- tool = self._server._local_provider._components[key]
- if not isinstance(tool, Tool):
+ # 1. Check local tools first - find ALL versions of this tool
+ # Keys are "tool:name@" (unversioned) or "tool:name@version" (versioned)
+ key_prefix = f"{Tool.make_key(name)}@"
+ matching_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == key_prefix or k.startswith(key_prefix)
+ ]
+ if matching_keys:
+ # Get the highest version tool to return
+ tool = await self._server.get_tool(name)
+ if tool is None or not isinstance(tool, Tool):
raise NotFoundError(f"Unknown tool: {name!r}")
- self._server.disable(keys=[key])
+ self._server.disable(keys=matching_keys)
return tool
# 2. Check mounted servers via FastMCPProvider
@@ -153,20 +167,32 @@ class ComponentService:
uri: The URI of the resource to enable
Returns:
- The resource that was enabled
+ The resource that was enabled (highest version)
"""
logger.debug("Enabling resource: %s", uri)
- # 1. Check local components first (try resource, then template)
- resource_key = Resource.make_key(uri)
- template_key = ResourceTemplate.make_key(uri)
- component = self._server._local_provider._get_component(
- resource_key
- ) or self._server._local_provider._get_component(template_key)
- if component is not None:
- key = resource_key if isinstance(component, Resource) else template_key
- self._server.enable(keys=[key])
- return component # type: ignore[return-value]
+ # 1. Check local components first - find ALL versions
+ # Keys are "resource:uri@" or "resource:uri@version" (and same for template)
+ resource_prefix = f"{Resource.make_key(uri)}@"
+ template_prefix = f"{ResourceTemplate.make_key(uri)}@"
+ resource_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == resource_prefix or k.startswith(resource_prefix)
+ ]
+ template_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == template_prefix or k.startswith(template_prefix)
+ ]
+ if resource_keys:
+ self._server.enable(keys=resource_keys)
+ resource = await self._server.get_resource(uri)
+ return resource
+ if template_keys:
+ self._server.enable(keys=template_keys)
+ template = await self._server.get_resource_template(uri)
+ return template
# 2. Check mounted servers via FastMCPProvider
for provider in self._server._providers:
@@ -187,20 +213,34 @@ class ComponentService:
uri: The URI of the resource to disable
Returns:
- The resource that was disabled
+ The resource that was disabled (highest version)
"""
logger.debug("Disabling resource: %s", uri)
- # 1. Check local components first (try resource, then template)
- resource_key = Resource.make_key(uri)
- template_key = ResourceTemplate.make_key(uri)
- component = self._server._local_provider._get_component(
- resource_key
- ) or self._server._local_provider._get_component(template_key)
- if component is not None:
- key = resource_key if isinstance(component, Resource) else template_key
- self._server.disable(keys=[key])
- return component # type: ignore[return-value]
+ # 1. Check local components first - find ALL versions
+ # Keys are "resource:uri@" or "resource:uri@version" (and same for template)
+ resource_prefix = f"{Resource.make_key(uri)}@"
+ template_prefix = f"{ResourceTemplate.make_key(uri)}@"
+ resource_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == resource_prefix or k.startswith(resource_prefix)
+ ]
+ template_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == template_prefix or k.startswith(template_prefix)
+ ]
+ if resource_keys:
+ # Get the highest version to return before disabling
+ resource = await self._server.get_resource(uri)
+ self._server.disable(keys=resource_keys)
+ return resource
+ if template_keys:
+ # Get the highest version to return before disabling
+ template = await self._server.get_resource_template(uri)
+ self._server.disable(keys=template_keys)
+ return template
# 2. Check mounted servers via FastMCPProvider
for provider in self._server._providers:
@@ -221,14 +261,20 @@ class ComponentService:
name: The name of the prompt to enable
Returns:
- The prompt that was enabled
+ The prompt that was enabled (highest version)
"""
logger.debug("Enabling prompt: %s", name)
- # 1. Check local prompts first. The server will have already applied its filter.
- key = Prompt.make_key(name)
- if key in self._server._local_provider._components:
- self._server.enable(keys=[key])
+ # 1. Check local prompts first - find ALL versions of this prompt
+ # Keys are "prompt:name@" (unversioned) or "prompt:name@version" (versioned)
+ key_prefix = f"{Prompt.make_key(name)}@"
+ matching_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == key_prefix or k.startswith(key_prefix)
+ ]
+ if matching_keys:
+ self._server.enable(keys=matching_keys)
prompt = await self._server.get_prompt(name)
if prompt is None:
raise NotFoundError(f"Unknown prompt: {name}")
@@ -251,17 +297,24 @@ class ComponentService:
name: The name of the prompt to disable
Returns:
- The prompt that was disabled
+ The prompt that was disabled (highest version)
"""
logger.debug("Disabling prompt: %s", name)
- # 1. Check local prompts first. The server will have already applied its filter.
- key = Prompt.make_key(name)
- if key in self._server._local_provider._components:
- prompt = self._server._local_provider._components[key]
- if not isinstance(prompt, Prompt):
+ # 1. Check local prompts first - find ALL versions of this prompt
+ # Keys are "prompt:name@" (unversioned) or "prompt:name@version" (versioned)
+ key_prefix = f"{Prompt.make_key(name)}@"
+ matching_keys = [
+ k
+ for k in self._server._local_provider._components
+ if k == key_prefix or k.startswith(key_prefix)
+ ]
+ if matching_keys:
+ # Get the highest version prompt to return
+ prompt = await self._server.get_prompt(name)
+ if prompt is None or not isinstance(prompt, Prompt):
raise NotFoundError(f"Unknown prompt: {name}")
- self._server.disable(keys=[key])
+ self._server.disable(keys=matching_keys)
return prompt
# 2. Check mounted servers via FastMCPProvider
diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py
index 0174fe42b..4be4ae68a 100644
--- a/src/fastmcp/prompts/function_prompt.py
+++ b/src/fastmcp/prompts/function_prompt.py
@@ -59,6 +59,7 @@ class PromptMeta:
type: Literal["prompt"] = field(default="prompt", init=False)
name: str | None = None
+ version: str | int | None = None
title: str | None = None
description: str | None = None
icons: list[Icon] | None = None
@@ -81,6 +82,7 @@ class FunctionPrompt(Prompt):
metadata: PromptMeta | None = None,
# Keep individual params for backwards compat
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -106,7 +108,7 @@ class FunctionPrompt(Prompt):
# Check mutual exclusion
individual_params_provided = any(
x is not None
- for x in [name, title, description, icons, tags, meta, task, auth]
+ for x in [name, version, title, description, icons, tags, meta, task, auth]
)
if metadata is not None and individual_params_provided:
@@ -119,6 +121,7 @@ class FunctionPrompt(Prompt):
if metadata is None:
metadata = PromptMeta(
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -217,6 +220,7 @@ class FunctionPrompt(Prompt):
return cls(
name=func_name,
+ version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
description=description,
icons=metadata.icons,
@@ -350,6 +354,7 @@ def prompt(fn: F) -> F: ...
def prompt(
name_or_fn: str,
*,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -363,6 +368,7 @@ def prompt(
name_or_fn: None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -377,6 +383,7 @@ def prompt(
name_or_fn: str | Callable[..., Any] | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -402,6 +409,7 @@ def prompt(
# Create metadata first, then pass it
prompt_meta = PromptMeta(
name=prompt_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -415,6 +423,7 @@ def prompt(
def attach_metadata(fn: F, prompt_name: str | None) -> F:
metadata = PromptMeta(
name=prompt_name,
+ version=version,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py
index b84668e35..fde2c9e4f 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -231,6 +231,7 @@ class Prompt(FastMCPComponent):
fn: Callable[..., Any],
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -251,6 +252,7 @@ class Prompt(FastMCPComponent):
return FunctionPrompt.from_function(
fn=fn,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py
index dab197ce8..3d57d0dbc 100644
--- a/src/fastmcp/resources/function_resource.py
+++ b/src/fastmcp/resources/function_resource.py
@@ -46,6 +46,7 @@ class ResourceMeta:
type: Literal["resource"] = field(default="resource", init=False)
uri: str
name: str | None = None
+ version: str | int | None = None
title: str | None = None
description: str | None = None
icons: list[Icon] | None = None
@@ -81,6 +82,7 @@ class FunctionResource(Resource):
metadata: ResourceMeta | None = None,
# Keep individual params for backwards compat
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -107,6 +109,7 @@ class FunctionResource(Resource):
x is not None
for x in [
name,
+ version,
title,
description,
icons,
@@ -134,6 +137,7 @@ class FunctionResource(Resource):
metadata = ResourceMeta(
uri=str(uri),
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -179,6 +183,7 @@ class FunctionResource(Resource):
fn=wrapped_fn,
uri=uri_obj,
name=func_name,
+ version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
description=metadata.description or inspect.getdoc(fn),
icons=metadata.icons,
@@ -226,6 +231,7 @@ def resource(
uri: str,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -263,6 +269,7 @@ def resource(
resource_meta = ResourceMeta(
uri=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -280,6 +287,7 @@ def resource(
fn=fn,
uri_template=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -297,6 +305,7 @@ def resource(
metadata = ResourceMeta(
uri=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py
index 0894996d1..5dd907348 100644
--- a/src/fastmcp/resources/resource.py
+++ b/src/fastmcp/resources/resource.py
@@ -237,6 +237,7 @@ class Resource(FastMCPComponent):
uri: str | AnyUrl,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -255,6 +256,7 @@ class Resource(FastMCPComponent):
fn=fn,
uri=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -380,7 +382,8 @@ class Resource(FastMCPComponent):
@property
def key(self) -> str:
"""The globally unique lookup key for this resource."""
- return self.make_key(str(self.uri))
+ base_key = self.make_key(str(self.uri))
+ return f"{base_key}@{self.version or ''}"
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution."""
diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py
index 40b63de71..63c77e2c3 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -129,6 +129,7 @@ class ResourceTemplate(FastMCPComponent):
fn: Callable[..., Any],
uri_template: str,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -143,6 +144,7 @@ class ResourceTemplate(FastMCPComponent):
fn=fn,
uri_template=uri_template,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -284,7 +286,8 @@ class ResourceTemplate(FastMCPComponent):
@property
def key(self) -> str:
"""The globally unique lookup key for this template."""
- return self.make_key(self.uri_template)
+ base_key = self.make_key(self.uri_template)
+ return f"{base_key}@{self.version or ''}"
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution."""
@@ -459,6 +462,7 @@ class FunctionResourceTemplate(ResourceTemplate):
fn: Callable[..., Any],
uri_template: str,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -568,6 +572,7 @@ class FunctionResourceTemplate(ResourceTemplate):
return cls(
uri_template=uri_template,
name=func_name,
+ version=str(version) if version is not None else None,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/server/providers/aggregate.py b/src/fastmcp/server/providers/aggregate.py
index 9beb6a917..f080e58d5 100644
--- a/src/fastmcp/server/providers/aggregate.py
+++ b/src/fastmcp/server/providers/aggregate.py
@@ -20,6 +20,7 @@ from fastmcp.server.providers.base import Provider
from fastmcp.tools.tool import Tool
from fastmcp.utilities.async_utils import gather
from fastmcp.utilities.components import FastMCPComponent
+from fastmcp.utilities.versions import VersionSpec, version_sort_key
logger = logging.getLogger(__name__)
@@ -30,10 +31,9 @@ class AggregateProvider(Provider):
"""Presents multiple providers as a single provider.
Components are aggregated from all providers. For get_* operations,
- providers are queried in parallel and the first non-None result is returned.
+ providers are queried in parallel and the highest version is returned.
Errors from individual providers are logged and skipped (graceful degradation).
- This matches the behavior of FastMCP's original provider iteration.
"""
def __init__(self, providers: Sequence[Provider]) -> None:
@@ -77,6 +77,31 @@ class AggregateProvider(Provider):
return result
return None
+ def _get_highest_version_result(
+ self,
+ results: list[FastMCPComponent | None | BaseException],
+ operation: str,
+ ) -> FastMCPComponent | None:
+ """Get the highest version from successful non-None results.
+
+ Used for versioned components where we want the highest version
+ across all providers rather than the first match.
+ """
+ valid: list[FastMCPComponent] = []
+ for i, result in enumerate(results):
+ if isinstance(result, BaseException):
+ if not isinstance(result, NotFoundError):
+ logger.debug(
+ f"Error during {operation} from provider "
+ f"{self._providers[i]}: {result}"
+ )
+ continue
+ if result is not None:
+ valid.append(result)
+ if not valid:
+ return None
+ return max(valid, key=version_sort_key)
+
def __repr__(self) -> str:
return f"AggregateProvider(providers={self._providers!r})"
@@ -92,13 +117,21 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_tools")
- async def get_tool(self, name: str) -> Tool | None:
- """Get tool by name from first provider that has it (with transforms applied)."""
+ async def get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
+ """Get tool by name.
+
+ Args:
+ name: The tool name.
+ version: If None, returns highest version across all providers.
+ If specified, returns highest version matching the spec from any provider.
+ """
results = await gather(
- *[p._get_tool(name) for p in self._providers],
+ *[p._get_tool(name, version) for p in self._providers],
return_exceptions=True,
)
- return self._get_first_result(results, f"get_tool({name!r})")
+ return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value]
# -------------------------------------------------------------------------
# Resources
@@ -112,13 +145,21 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_resources")
- async def get_resource(self, uri: str) -> Resource | None:
- """Get resource by URI from first provider that has it (with transforms applied)."""
+ async def get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
+ """Get resource by URI.
+
+ Args:
+ uri: The resource URI.
+ version: If None, returns highest version across all providers.
+ If specified, returns highest version matching the spec from any provider.
+ """
results = await gather(
- *[p._get_resource(uri) for p in self._providers],
+ *[p._get_resource(uri, version) for p in self._providers],
return_exceptions=True,
)
- return self._get_first_result(results, f"get_resource({uri!r})")
+ return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value]
# -------------------------------------------------------------------------
# Resource Templates
@@ -132,13 +173,23 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_resource_templates")
- async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
- """Get resource template by URI from first provider that has it (with transforms applied)."""
+ async def get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
+ """Get resource template by URI.
+
+ Args:
+ uri: The template URI to match.
+ version: If None, returns highest version across all providers.
+ If specified, returns highest version matching the spec from any provider.
+ """
results = await gather(
- *[p._get_resource_template(uri) for p in self._providers],
+ *[p._get_resource_template(uri, version) for p in self._providers],
return_exceptions=True,
)
- return self._get_first_result(results, f"get_resource_template({uri!r})")
+ return self._get_highest_version_result(
+ results, f"get_resource_template({uri!r})"
+ ) # type: ignore[return-value]
# -------------------------------------------------------------------------
# Prompts
@@ -152,36 +203,21 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_prompts")
- async def get_prompt(self, name: str) -> Prompt | None:
- """Get prompt by name from first provider that has it (with transforms applied)."""
+ async def get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
+ """Get prompt by name.
+
+ Args:
+ name: The prompt name.
+ version: If None, returns highest version across all providers.
+ If specified, returns highest version matching the spec from any provider.
+ """
results = await gather(
- *[p._get_prompt(name) for p in self._providers],
+ *[p._get_prompt(name, version) for p in self._providers],
return_exceptions=True,
)
- return self._get_first_result(results, f"get_prompt({name!r})")
-
- # -------------------------------------------------------------------------
- # Components
- # -------------------------------------------------------------------------
-
- async def get_component(
- self, key: str
- ) -> Tool | Resource | ResourceTemplate | Prompt | None:
- """Get component by key from first provider that has it (with transforms applied).
-
- Parses the key prefix and delegates to the appropriate get_* method
- so that transforms are applied correctly.
- """
- # Parse key prefix to route to correct method
- if key.startswith("tool:"):
- return await self.get_tool(key[5:])
- elif key.startswith("resource:"):
- return await self.get_resource(key[9:])
- elif key.startswith("template:"):
- return await self.get_resource_template(key[9:])
- elif key.startswith("prompt:"):
- return await self.get_prompt(key[7:])
- return None
+ return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value]
# -------------------------------------------------------------------------
# Tasks
diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py
index 239903cb3..3e5dce745 100644
--- a/src/fastmcp/server/providers/base.py
+++ b/src/fastmcp/server/providers/base.py
@@ -40,6 +40,7 @@ from fastmcp.server.transforms.visibility import Visibility
from fastmcp.tools.tool import Tool
from fastmcp.utilities.async_utils import gather
from fastmcp.utilities.components import FastMCPComponent
+from fastmcp.utilities.versions import VersionSpec, version_sort_key
if TYPE_CHECKING:
from fastmcp.server.transforms import Transform
@@ -114,24 +115,27 @@ class Provider:
return await chain()
- async def _get_tool(self, name: str) -> Tool | None:
+ async def _get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get tool by transformed name with all transforms applied.
Args:
name: The transformed tool name to look up.
+ version: Optional version filter. If None, returns highest version.
Returns:
The tool if found and enabled, None otherwise.
"""
- async def base(n: str) -> Tool | None:
- return await self.get_tool(n)
+ async def base(n: str, version: VersionSpec | None = None) -> Tool | None:
+ return await self.get_tool(n, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_tool, call_next=chain)
- return await chain(name)
+ return await chain(name, version=version)
async def _list_resources(self) -> Sequence[Resource]:
"""List resources with all transforms applied."""
@@ -145,17 +149,24 @@ class Provider:
return await chain()
- async def _get_resource(self, uri: str) -> Resource | None:
- """Get resource by transformed URI with all transforms applied."""
+ async def _get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
+ """Get resource by transformed URI with all transforms applied.
- async def base(u: str) -> Resource | None:
- return await self.get_resource(u)
+ Args:
+ uri: The transformed resource URI to look up.
+ version: Optional version filter. If None, returns highest version.
+ """
+
+ async def base(u: str, version: VersionSpec | None = None) -> Resource | None:
+ return await self.get_resource(u, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_resource, call_next=chain)
- return await chain(uri)
+ return await chain(uri, version=version)
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""List resource templates with all transforms applied."""
@@ -169,17 +180,26 @@ class Provider:
return await chain()
- async def _get_resource_template(self, uri: str) -> ResourceTemplate | None:
- """Get resource template by transformed URI with all transforms applied."""
+ async def _get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
+ """Get resource template by transformed URI with all transforms applied.
- async def base(u: str) -> ResourceTemplate | None:
- return await self.get_resource_template(u)
+ Args:
+ uri: The transformed template URI to look up.
+ version: Optional version filter. If None, returns highest version.
+ """
+
+ async def base(
+ u: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
+ return await self.get_resource_template(u, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_resource_template, call_next=chain)
- return await chain(uri)
+ return await chain(uri, version=version)
async def _list_prompts(self) -> Sequence[Prompt]:
"""List prompts with all transforms applied."""
@@ -193,17 +213,24 @@ class Provider:
return await chain()
- async def _get_prompt(self, name: str) -> Prompt | None:
- """Get prompt by transformed name with all transforms applied."""
+ async def _get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
+ """Get prompt by transformed name with all transforms applied.
- async def base(n: str) -> Prompt | None:
- return await self.get_prompt(n)
+ Args:
+ name: The transformed prompt name to look up.
+ version: Optional version filter. If None, returns highest version.
+ """
+
+ async def base(n: str, version: VersionSpec | None = None) -> Prompt | None:
+ return await self.get_prompt(n, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_prompt, call_next=chain)
- return await chain(name)
+ return await chain(name, version=version)
# -------------------------------------------------------------------------
# Public list/get methods (override these to provide components)
@@ -212,82 +239,127 @@ class Provider:
async def list_tools(self) -> Sequence[Tool]:
"""Return all available tools.
- Override to provide tools dynamically.
+ Override to provide tools dynamically. Returns ALL versions of all tools.
+ The server handles deduplication to show one tool per name.
"""
return []
- async def get_tool(self, name: str) -> Tool | None:
+ async def get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get a specific tool by name.
- Default implementation lists all tools and finds by name.
- Override for more efficient single-tool lookup.
+ Default implementation filters list_tools() and picks the highest version
+ that matches the spec.
+
+ Args:
+ name: The tool name.
+ version: Optional version filter. If None, returns highest version.
+ If specified, returns highest version matching the spec.
Returns:
The Tool if found, or None to continue searching other providers.
"""
tools = await self.list_tools()
- return next((t for t in tools if t.name == name), None)
+ matching = [t for t in tools if t.name == name]
+ if version:
+ matching = [t for t in matching if version.matches(t.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resources(self) -> Sequence[Resource]:
"""Return all available resources.
- Override to provide resources dynamically.
+ Override to provide resources dynamically. Returns ALL versions of all resources.
+ The server handles deduplication to show one resource per URI.
"""
return []
- async def get_resource(self, uri: str) -> Resource | None:
+ async def get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
"""Get a specific resource by URI.
- Default implementation lists all resources and finds by URI.
- Override for more efficient single-resource lookup.
+ Default implementation filters list_resources() and returns highest
+ version matching the spec.
+
+ Args:
+ uri: The resource URI.
+ version: Optional version filter. If None, returns highest version.
Returns:
The Resource if found, or None to continue searching other providers.
"""
resources = await self.list_resources()
- return next((r for r in resources if str(r.uri) == uri), None)
+ matching = [r for r in resources if str(r.uri) == uri]
+ if version:
+ matching = [r for r in matching if version.matches(r.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all available resource templates.
- Override to provide resource templates dynamically.
+ Override to provide resource templates dynamically. Returns ALL versions.
+ The server handles deduplication.
"""
return []
- async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
+ async def get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI.
- Default implementation lists all templates and finds one whose pattern
- matches the URI.
- Override for more efficient lookup.
+ Default implementation lists all templates, finds those whose pattern
+ matches the URI, and returns the highest version matching the spec.
+
+ Args:
+ uri: The URI to match against templates.
+ version: Optional version filter. If None, returns highest version.
Returns:
The ResourceTemplate if a matching one is found, or None to continue searching.
"""
templates = await self.list_resource_templates()
- return next(
- (t for t in templates if t.matches(uri) is not None),
- None,
- )
+ matching = [t for t in templates if t.matches(uri) is not None]
+ if version:
+ matching = [t for t in matching if version.matches(t.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all available prompts.
- Override to provide prompts dynamically.
+ Override to provide prompts dynamically. Returns ALL versions of all prompts.
+ The server handles deduplication to show one prompt per name.
"""
return []
- async def get_prompt(self, name: str) -> Prompt | None:
+ async def get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
"""Get a specific prompt by name.
- Default implementation lists all prompts and finds by name.
- Override for more efficient single-prompt lookup.
+ Default implementation filters list_prompts() and picks the highest version
+ matching the spec.
+
+ Args:
+ name: The prompt name.
+ version: Optional version filter. If None, returns highest version.
Returns:
The Prompt if found, or None to continue searching other providers.
"""
prompts = await self.list_prompts()
- return next((p for p in prompts if p.name == name), None)
+ matching = [p for p in prompts if p.name == name]
+ if version:
+ matching = [p for p in matching if version.matches(p.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
# -------------------------------------------------------------------------
# Task registration
@@ -406,7 +478,7 @@ class Provider:
"""Enable components by removing from blocklist, or set allowlist with only=True.
Args:
- keys: Keys to enable (e.g., "tool:my_tool").
+ keys: Keys to enable (e.g., "tool:my_tool@" for unversioned, "tool:my_tool@1.0" for versioned).
tags: Tags to enable - components with these tags will be enabled.
only: If True, switches to allowlist mode - ONLY show these keys/tags.
"""
@@ -421,7 +493,7 @@ class Provider:
"""Disable components by adding to the blocklist.
Args:
- keys: Keys to disable (e.g., "tool:my_tool").
+ keys: Keys to disable (e.g., "tool:my_tool@" for unversioned, "tool:my_tool@1.0" for versioned).
tags: Tags to disable - components with these tags will be disabled.
"""
self._visibility.disable(keys=keys, tags=tags)
diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py
index 846775eb8..b7a56ab9a 100644
--- a/src/fastmcp/server/providers/fastmcp_provider.py
+++ b/src/fastmcp/server/providers/fastmcp_provider.py
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, overload
import mcp.types
from mcp.types import AnyUrl
+from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
@@ -27,6 +28,7 @@ from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.telemetry import delegate_span
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.components import FastMCPComponent
+from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from docket import Docket
@@ -79,6 +81,7 @@ class FastMCPProviderTool(Tool):
server=server,
original_name=tool.name,
name=tool.name,
+ version=tool.version,
description=tool.description,
parameters=tool.parameters,
output_schema=tool.output_schema,
@@ -167,6 +170,7 @@ class FastMCPProviderResource(Resource):
server=server,
original_uri=str(resource.uri),
uri=resource.uri,
+ version=resource.version,
name=resource.name,
description=resource.description,
mime_type=resource.mime_type,
@@ -231,6 +235,7 @@ class FastMCPProviderPrompt(Prompt):
server=server,
original_name=prompt.name,
name=prompt.name,
+ version=prompt.version,
description=prompt.description,
arguments=prompt.arguments,
tags=prompt.tags,
@@ -320,6 +325,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
server=server,
original_uri_template=template.uri_template,
uri_template=template.uri_template,
+ version=template.version,
name=template.name,
description=template.description,
mime_type=template.mime_type,
@@ -487,10 +493,19 @@ class FastMCPProvider(Provider):
raw_tools = await self.server.get_tools(run_middleware=True)
return [FastMCPProviderTool.wrap(self.server, t) for t in raw_tools]
- async def get_tool(self, name: str) -> Tool | None:
- """Get a tool by name as a FastMCPProviderTool."""
- tools = await self.list_tools()
- return next((t for t in tools if t.name == name), None)
+ async def get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
+ """Get a tool by name as a FastMCPProviderTool.
+
+ Passes the full VersionSpec to the nested server, which handles both
+ exact version matching and range filtering.
+ """
+ try:
+ raw_tool = await self.server.get_tool(name, version)
+ except NotFoundError:
+ return None
+ return FastMCPProviderTool.wrap(self.server, raw_tool)
# -------------------------------------------------------------------------
# Resource methods
@@ -506,10 +521,19 @@ class FastMCPProvider(Provider):
raw_resources = await self.server.get_resources(run_middleware=True)
return [FastMCPProviderResource.wrap(self.server, r) for r in raw_resources]
- async def get_resource(self, uri: str) -> Resource | None:
- """Get a concrete resource by URI as a FastMCPProviderResource."""
- resources = await self.list_resources()
- return next((r for r in resources if str(r.uri) == uri), None)
+ async def get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
+ """Get a concrete resource by URI as a FastMCPProviderResource.
+
+ Passes the full VersionSpec to the nested server, which handles both
+ exact version matching and range filtering.
+ """
+ try:
+ raw_resource = await self.server.get_resource(uri, version)
+ except NotFoundError:
+ return None
+ return FastMCPProviderResource.wrap(self.server, raw_resource)
# -------------------------------------------------------------------------
# Resource template methods
@@ -526,13 +550,19 @@ class FastMCPProvider(Provider):
FastMCPProviderResourceTemplate.wrap(self.server, t) for t in raw_templates
]
- async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
- """Get a resource template that matches the given URI."""
- templates = await self.list_resource_templates()
- for template in templates:
- if template.matches(uri) is not None:
- return template
- return None
+ async def get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
+ """Get a resource template that matches the given URI.
+
+ Passes the full VersionSpec to the nested server, which handles both
+ exact version matching and range filtering.
+ """
+ try:
+ raw_template = await self.server.get_resource_template(uri, version)
+ except NotFoundError:
+ return None
+ return FastMCPProviderResourceTemplate.wrap(self.server, raw_template)
# -------------------------------------------------------------------------
# Prompt methods
@@ -547,10 +577,19 @@ class FastMCPProvider(Provider):
raw_prompts = await self.server.get_prompts(run_middleware=True)
return [FastMCPProviderPrompt.wrap(self.server, p) for p in raw_prompts]
- async def get_prompt(self, name: str) -> Prompt | None:
- """Get a prompt by name as a FastMCPProviderPrompt."""
- prompts = await self.list_prompts()
- return next((p for p in prompts if p.name == name), None)
+ async def get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
+ """Get a prompt by name as a FastMCPProviderPrompt.
+
+ Passes the full VersionSpec to the nested server, which handles both
+ exact version matching and range filtering.
+ """
+ try:
+ raw_prompt = await self.server.get_prompt(name, version)
+ except NotFoundError:
+ return None
+ return FastMCPProviderPrompt.wrap(self.server, raw_prompt)
# -------------------------------------------------------------------------
# Task registration
diff --git a/src/fastmcp/server/providers/filesystem.py b/src/fastmcp/server/providers/filesystem.py
index 18e1bfb83..9225ff636 100644
--- a/src/fastmcp/server/providers/filesystem.py
+++ b/src/fastmcp/server/providers/filesystem.py
@@ -39,6 +39,7 @@ from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.tools.tool import Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
+from fastmcp.utilities.versions import VersionSpec
logger = get_logger(__name__)
@@ -178,40 +179,48 @@ class FileSystemProvider(LocalProvider):
await self._ensure_loaded()
return await super().list_tools()
- async def get_tool(self, name: str) -> Tool | None:
+ async def get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get a tool by name, reloading if in reload mode."""
await self._ensure_loaded()
- return await super().get_tool(name)
+ return await super().get_tool(name, version)
async def list_resources(self) -> Sequence[Resource]:
"""Return all resources, reloading if in reload mode."""
await self._ensure_loaded()
return await super().list_resources()
- async def get_resource(self, uri: str) -> Resource | None:
+ async def get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
"""Get a resource by URI, reloading if in reload mode."""
await self._ensure_loaded()
- return await super().get_resource(uri)
+ return await super().get_resource(uri, version)
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates, reloading if in reload mode."""
await self._ensure_loaded()
return await super().list_resource_templates()
- async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
+ async def get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
"""Get a resource template, reloading if in reload mode."""
await self._ensure_loaded()
- return await super().get_resource_template(uri)
+ return await super().get_resource_template(uri, version)
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all prompts, reloading if in reload mode."""
await self._ensure_loaded()
return await super().list_prompts()
- async def get_prompt(self, name: str) -> Prompt | None:
+ async def get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
"""Get a prompt by name, reloading if in reload mode."""
await self._ensure_loaded()
- return await super().get_prompt(name)
+ return await super().get_prompt(name, version)
def __repr__(self) -> str:
return f"FileSystemProvider(root={self._root!r}, reload={self._reload})"
diff --git a/src/fastmcp/server/providers/local_provider.py b/src/fastmcp/server/providers/local_provider.py
index 7b6dae434..61c2b64fa 100644
--- a/src/fastmcp/server/providers/local_provider.py
+++ b/src/fastmcp/server/providers/local_provider.py
@@ -46,6 +46,7 @@ from fastmcp.tools.tool import AuthCheckCallable, Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import NotSet, NotSetT
+from fastmcp.utilities.versions import VersionSpec, version_sort_key
if TYPE_CHECKING:
from fastmcp.tools.tool import ToolResultSerializerType
@@ -129,6 +130,71 @@ class LocalProvider(Provider):
# Storage methods
# =========================================================================
+ def _get_component_identity(self, component: FastMCPComponent) -> tuple[type, str]:
+ """Get the identity (type, name/uri) for a component.
+
+ Returns:
+ A tuple of (component_type, logical_name) where logical_name is
+ the name for tools/prompts or URI for resources/templates.
+ """
+ if isinstance(component, Tool):
+ return (Tool, component.name)
+ elif isinstance(component, ResourceTemplate):
+ return (ResourceTemplate, component.uri_template)
+ elif isinstance(component, Resource):
+ return (Resource, str(component.uri))
+ elif isinstance(component, Prompt):
+ return (Prompt, component.name)
+ else:
+ # Fall back to key without version suffix
+ key = component.key
+ base_key = key.rsplit("@", 1)[0] if "@" in key else key
+ return (type(component), base_key)
+
+ def _check_version_mixing(self, component: _C) -> None:
+ """Check that versioned and unversioned components aren't mixed.
+
+ LocalProvider enforces a simple rule: for any given name/URI, all
+ registered components must either be versioned or unversioned, not both.
+ This prevents confusing situations where unversioned components can't
+ be filtered out by version filters.
+
+ Args:
+ component: The component being added.
+
+ Raises:
+ ValueError: If adding would mix versioned and unversioned components.
+ """
+ comp_type, logical_name = self._get_component_identity(component)
+ is_versioned = component.version is not None
+
+ # Check all existing components of the same type and logical name
+ for existing in self._components.values():
+ if not isinstance(existing, comp_type):
+ continue
+
+ _, existing_name = self._get_component_identity(existing)
+ if existing_name != logical_name:
+ continue
+
+ existing_versioned = existing.version is not None
+ if is_versioned != existing_versioned:
+ type_name = comp_type.__name__.lower()
+ if is_versioned:
+ raise ValueError(
+ f"Cannot add versioned {type_name} {logical_name!r} "
+ f"(version={component.version!r}): an unversioned "
+ f"{type_name} with this name already exists. "
+ f"Either version all components or none."
+ )
+ else:
+ raise ValueError(
+ f"Cannot add unversioned {type_name} {logical_name!r}: "
+ f"versioned {type_name}s with this name already exist "
+ f"(e.g., version={existing.version!r}). "
+ f"Either version all components or none."
+ )
+
def _add_component(self, component: _C) -> _C:
"""Add a component to unified storage.
@@ -148,6 +214,9 @@ class LocalProvider(Provider):
return existing # type: ignore[return-value]
# "replace" and "warn" fall through to add
+ # Check for versioned/unversioned mixing before adding
+ self._check_version_mixing(component)
+
self._components[component.key] = component
self._send_list_changed_notification(component)
return component
@@ -194,6 +263,7 @@ class LocalProvider(Provider):
tool = Tool.from_function(
tool,
name=meta.name,
+ version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@@ -211,9 +281,33 @@ class LocalProvider(Provider):
tool = Tool.from_function(tool)
return self._add_component(tool)
- def remove_tool(self, name: str) -> None:
- """Remove a tool from this provider's storage."""
- self._remove_component(Tool.make_key(name))
+ def remove_tool(self, name: str, version: str | None = None) -> None:
+ """Remove tool(s) from this provider's storage.
+
+ Args:
+ name: The tool name.
+ version: If None, removes ALL versions. If specified, removes only that version.
+
+ Raises:
+ KeyError: If no matching tool is found.
+ """
+ if version is None:
+ # Remove all versions
+ keys_to_remove = [
+ k
+ for k, c in self._components.items()
+ if isinstance(c, Tool) and c.name == name
+ ]
+ if not keys_to_remove:
+ raise KeyError(f"Tool {name!r} not found")
+ for key in keys_to_remove:
+ self._remove_component(key)
+ else:
+ # Remove specific version - key format is "tool:name@version"
+ key = f"{Tool.make_key(name)}@{version}"
+ if key not in self._components:
+ raise KeyError(f"Tool {name!r} version {version!r} not found")
+ self._remove_component(key)
def add_resource(
self, resource: Resource | ResourceTemplate | Callable[..., Any]
@@ -239,6 +333,7 @@ class LocalProvider(Provider):
fn=resource,
uri_template=meta.uri,
name=meta.name,
+ version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@@ -254,6 +349,7 @@ class LocalProvider(Provider):
fn=resource,
uri=meta.uri,
name=meta.name,
+ version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@@ -271,17 +367,67 @@ class LocalProvider(Provider):
)
return self._add_component(resource)
- def remove_resource(self, uri: str) -> None:
- """Remove a resource from this provider's storage."""
- self._remove_component(Resource.make_key(uri))
+ def remove_resource(self, uri: str, version: str | None = None) -> None:
+ """Remove resource(s) from this provider's storage.
+
+ Args:
+ uri: The resource URI.
+ version: If None, removes ALL versions. If specified, removes only that version.
+
+ Raises:
+ KeyError: If no matching resource is found.
+ """
+ if version is None:
+ # Remove all versions
+ keys_to_remove = [
+ k
+ for k, c in self._components.items()
+ if isinstance(c, Resource) and str(c.uri) == uri
+ ]
+ if not keys_to_remove:
+ raise KeyError(f"Resource {uri!r} not found")
+ for key in keys_to_remove:
+ self._remove_component(key)
+ else:
+ # Remove specific version
+ key = f"{Resource.make_key(uri)}@{version}"
+ if key not in self._components:
+ raise KeyError(f"Resource {uri!r} version {version!r} not found")
+ self._remove_component(key)
def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
"""Add a resource template to this provider's storage."""
return self._add_component(template)
- def remove_template(self, uri_template: str) -> None:
- """Remove a resource template from this provider's storage."""
- self._remove_component(ResourceTemplate.make_key(uri_template))
+ def remove_template(self, uri_template: str, version: str | None = None) -> None:
+ """Remove resource template(s) from this provider's storage.
+
+ Args:
+ uri_template: The template URI pattern.
+ version: If None, removes ALL versions. If specified, removes only that version.
+
+ Raises:
+ KeyError: If no matching template is found.
+ """
+ if version is None:
+ # Remove all versions
+ keys_to_remove = [
+ k
+ for k, c in self._components.items()
+ if isinstance(c, ResourceTemplate) and c.uri_template == uri_template
+ ]
+ if not keys_to_remove:
+ raise KeyError(f"Template {uri_template!r} not found")
+ for key in keys_to_remove:
+ self._remove_component(key)
+ else:
+ # Remove specific version
+ key = f"{ResourceTemplate.make_key(uri_template)}@{version}"
+ if key not in self._components:
+ raise KeyError(
+ f"Template {uri_template!r} version {version!r} not found"
+ )
+ self._remove_component(key)
def add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt:
"""Add a prompt to this provider's storage.
@@ -298,6 +444,7 @@ class LocalProvider(Provider):
prompt = Prompt.from_function(
prompt,
name=meta.name,
+ version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@@ -313,9 +460,33 @@ class LocalProvider(Provider):
)
return self._add_component(prompt)
- def remove_prompt(self, name: str) -> None:
- """Remove a prompt from this provider's storage."""
- self._remove_component(Prompt.make_key(name))
+ def remove_prompt(self, name: str, version: str | None = None) -> None:
+ """Remove prompt(s) from this provider's storage.
+
+ Args:
+ name: The prompt name.
+ version: If None, removes ALL versions. If specified, removes only that version.
+
+ Raises:
+ KeyError: If no matching prompt is found.
+ """
+ if version is None:
+ # Remove all versions
+ keys_to_remove = [
+ k
+ for k, c in self._components.items()
+ if isinstance(c, Prompt) and c.name == name
+ ]
+ if not keys_to_remove:
+ raise KeyError(f"Prompt {name!r} not found")
+ for key in keys_to_remove:
+ self._remove_component(key)
+ else:
+ # Remove specific version
+ key = f"{Prompt.make_key(name)}@{version}"
+ if key not in self._components:
+ raise KeyError(f"Prompt {name!r} version {version!r} not found")
+ self._remove_component(key)
# =========================================================================
# Provider interface implementation
@@ -329,16 +500,25 @@ class LocalProvider(Provider):
if isinstance(v, Tool) and self._is_component_enabled(v)
]
- async def get_tool(self, name: str) -> Tool | None:
- """Get a tool by name."""
- tool = self._get_component(Tool.make_key(name))
- if (
- tool is not None
- and isinstance(tool, Tool)
- and self._is_component_enabled(tool)
- ):
- return tool
- return None
+ async def get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
+ """Get a tool by name.
+
+ Args:
+ name: The tool name.
+ version: Optional version filter. If None, returns highest version.
+ """
+ matching = [
+ v
+ for v in self._components.values()
+ if isinstance(v, Tool) and v.name == name and self._is_component_enabled(v)
+ ]
+ if version:
+ matching = [t for t in matching if version.matches(t.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resources(self) -> Sequence[Resource]:
"""Return all visible resources."""
@@ -348,12 +528,27 @@ class LocalProvider(Provider):
if isinstance(v, Resource) and self._is_component_enabled(v)
]
- async def get_resource(self, uri: str) -> Resource | None:
- """Get a resource by URI if visible."""
- component = self._components.get(Resource.make_key(uri))
- if isinstance(component, Resource) and self._is_component_enabled(component):
- return component
- return None
+ async def get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
+ """Get a resource by URI.
+
+ Args:
+ uri: The resource URI.
+ version: Optional version filter. If None, returns highest version.
+ """
+ matching = [
+ v
+ for v in self._components.values()
+ if isinstance(v, Resource)
+ and str(v.uri) == uri
+ and self._is_component_enabled(v)
+ ]
+ if version:
+ matching = [r for r in matching if version.matches(r.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all visible resource templates."""
@@ -363,16 +558,30 @@ class LocalProvider(Provider):
if isinstance(v, ResourceTemplate) and self._is_component_enabled(v)
]
- async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
- """Get a resource template that matches the given URI if visible."""
- for component in self._components.values():
+ async def get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
+ """Get a resource template that matches the given URI.
+
+ Args:
+ uri: The URI to match against templates.
+ version: Optional version filter. If None, returns highest version.
+ """
+ # Find all templates that match the URI
+ matching = [
+ component
+ for component in self._components.values()
if (
isinstance(component, ResourceTemplate)
and component.matches(uri) is not None
and self._is_component_enabled(component)
- ):
- return component
- return None
+ )
+ ]
+ if version:
+ matching = [t for t in matching if version.matches(t.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all visible prompts."""
@@ -382,24 +591,27 @@ class LocalProvider(Provider):
if isinstance(v, Prompt) and self._is_component_enabled(v)
]
- async def get_prompt(self, name: str) -> Prompt | None:
- """Get a prompt by name if visible."""
- component = self._components.get(Prompt.make_key(name))
- if isinstance(component, Prompt) and self._is_component_enabled(component):
- return component
- return None
+ async def get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
+ """Get a prompt by name.
- async def get_component(
- self, key: str
- ) -> Tool | Resource | ResourceTemplate | Prompt | None:
- """Get a component by its prefixed key.
-
- Efficient O(1) lookup in the unified components dict.
+ Args:
+ name: The prompt name.
+ version: Optional version filter. If None, returns highest version.
"""
- component = self._get_component(key)
- if component and self._is_component_enabled(component):
- return component # type: ignore[return-value]
- return None
+ matching = [
+ v
+ for v in self._components.values()
+ if isinstance(v, Prompt)
+ and v.name == name
+ and self._is_component_enabled(v)
+ ]
+ if version:
+ matching = [p for p in matching if version.matches(p.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
# =========================================================================
# Task registration
@@ -424,6 +636,7 @@ class LocalProvider(Provider):
name_or_fn: AnyFunction,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -445,6 +658,7 @@ class LocalProvider(Provider):
name_or_fn: str | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -469,6 +683,7 @@ class LocalProvider(Provider):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -573,6 +788,7 @@ class LocalProvider(Provider):
tool_obj = Tool.from_function(
fn,
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -595,6 +811,7 @@ class LocalProvider(Provider):
metadata = ToolMeta(
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -638,6 +855,7 @@ class LocalProvider(Provider):
return partial(
self.tool,
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -658,6 +876,7 @@ class LocalProvider(Provider):
uri: str,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -741,6 +960,7 @@ class LocalProvider(Provider):
create_resource = standalone_resource(
uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -767,6 +987,7 @@ class LocalProvider(Provider):
metadata = ResourceMeta(
uri=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -792,6 +1013,7 @@ class LocalProvider(Provider):
name_or_fn: AnyFunction,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -808,6 +1030,7 @@ class LocalProvider(Provider):
name_or_fn: str | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -823,6 +1046,7 @@ class LocalProvider(Provider):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -908,6 +1132,7 @@ class LocalProvider(Provider):
prompt_obj = Prompt.from_function(
fn,
name=prompt_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -925,6 +1150,7 @@ class LocalProvider(Provider):
metadata = PromptMeta(
name=prompt_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -958,6 +1184,7 @@ class LocalProvider(Provider):
return partial(
self.prompt,
name=prompt_name,
+ version=version,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py
index 38e5da241..3f216aef0 100644
--- a/src/fastmcp/server/providers/openapi/provider.py
+++ b/src/fastmcp/server/providers/openapi/provider.py
@@ -36,6 +36,7 @@ from fastmcp.utilities.openapi import (
parse_openapi_to_http_routes,
)
from fastmcp.utilities.openapi.director import RequestDirector
+from fastmcp.utilities.versions import VersionSpec, version_sort_key
__all__ = [
"OpenAPIProvider",
@@ -352,28 +353,48 @@ class OpenAPIProvider(Provider):
"""Return all tools created from the OpenAPI spec."""
return list(self._tools.values())
- async def get_tool(self, name: str) -> Tool | None:
+ async def get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get a tool by name."""
- return self._tools.get(name)
+ tool = self._tools.get(name)
+ if tool is None:
+ return None
+ if version is not None and not version.matches(tool.version):
+ return None
+ return tool
async def list_resources(self) -> Sequence[Resource]:
"""Return all resources created from the OpenAPI spec."""
return list(self._resources.values())
- async def get_resource(self, uri: str) -> Resource | None:
+ async def get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
"""Get a resource by URI."""
- return self._resources.get(uri)
+ resource = self._resources.get(uri)
+ if resource is None:
+ return None
+ if version is not None and not version.matches(resource.version):
+ return None
+ return resource
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates created from the OpenAPI spec."""
return list(self._templates.values())
- async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
+ async def get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
- return next(
- (t for t in self._templates.values() if t.matches(uri) is not None),
- None,
- )
+ matching = [t for t in self._templates.values() if t.matches(uri) is not None]
+ if not matching:
+ return None
+ if version is not None:
+ matching = [t for t in matching if version.matches(t.version)]
+ if not matching:
+ return None
+ return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_prompts(self) -> Sequence[Prompt]:
"""Return empty list - OpenAPI doesn't create prompts."""
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 123fe582f..f83e8224b 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -103,6 +103,7 @@ 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
if TYPE_CHECKING:
from docket import Docket
@@ -825,7 +826,11 @@ class FastMCP(Generic[LifespanResultT]):
# -------------------------------------------------------------------------
async def _source_list_tools(self) -> Sequence[Tool]:
- """List tools with all transforms applied (provider + server)."""
+ """List tools with all transforms applied (provider + server).
+
+ Returns all versions of all tools. Caller is responsible for deduplication
+ if showing to clients (keeping highest version per name).
+ """
root = self._get_root_provider()
async def base() -> Sequence[Tool]:
@@ -837,21 +842,32 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
- async def _source_get_tool(self, name: str) -> Tool | None:
- """Get tool by name with all transforms applied (provider + server)."""
+ async def _source_get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
+ """Get tool by name with all transforms applied (provider + server).
+
+ Args:
+ name: The tool name.
+ version: Optional version filter. If None, returns highest version.
+ """
root = self._get_root_provider()
- async def base(n: str) -> Tool | None:
- return await root.get_tool(n)
+ async def base(n: str, version: VersionSpec | None = None) -> Tool | None:
+ return await root._get_tool(n, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_tool, call_next=chain)
- return await chain(name)
+ return await chain(name, version=version)
async def _source_list_resources(self) -> Sequence[Resource]:
- """List resources with all transforms applied (provider + server)."""
+ """List resources with all transforms applied (provider + server).
+
+ Returns all versions of all resources. Caller is responsible for deduplication
+ if showing to clients (keeping highest version per URI).
+ """
root = self._get_root_provider()
async def base() -> Sequence[Resource]:
@@ -863,21 +879,32 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
- async def _source_get_resource(self, uri: str) -> Resource | None:
- """Get resource by URI with all transforms applied (provider + server)."""
+ async def _source_get_resource(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> Resource | None:
+ """Get resource by URI with all transforms applied (provider + server).
+
+ Args:
+ uri: The resource URI.
+ version: Optional version filter. If None, returns highest version.
+ """
root = self._get_root_provider()
- async def base(u: str) -> Resource | None:
- return await root.get_resource(u)
+ async def base(u: str, version: VersionSpec | None = None) -> Resource | None:
+ return await root._get_resource(u, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_resource, call_next=chain)
- return await chain(uri)
+ return await chain(uri, version=version)
async def _source_list_resource_templates(self) -> Sequence[ResourceTemplate]:
- """List resource templates with all transforms applied (provider + server)."""
+ """List resource templates with all transforms applied (provider + server).
+
+ Returns all versions of all templates. Caller is responsible for deduplication
+ if showing to clients (keeping highest version per uri_template).
+ """
root = self._get_root_provider()
async def base() -> Sequence[ResourceTemplate]:
@@ -889,21 +916,34 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
- async def _source_get_resource_template(self, uri: str) -> ResourceTemplate | None:
- """Get resource template by URI with all transforms applied (provider + server)."""
+ async def _source_get_resource_template(
+ self, uri: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
+ """Get resource template by URI with all transforms applied (provider + server).
+
+ Args:
+ uri: The template URI to match.
+ version: Optional version filter. If None, returns highest version.
+ """
root = self._get_root_provider()
- async def base(u: str) -> ResourceTemplate | None:
- return await root.get_resource_template(u)
+ async def base(
+ u: str, version: VersionSpec | None = None
+ ) -> ResourceTemplate | None:
+ return await root._get_resource_template(u, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_resource_template, call_next=chain)
- return await chain(uri)
+ return await chain(uri, version=version)
async def _source_list_prompts(self) -> Sequence[Prompt]:
- """List prompts with all transforms applied (provider + server)."""
+ """List prompts with all transforms applied (provider + server).
+
+ Returns all versions of all prompts. Caller is responsible for deduplication
+ if showing to clients (keeping highest version per name).
+ """
root = self._get_root_provider()
async def base() -> Sequence[Prompt]:
@@ -915,18 +955,25 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
- async def _source_get_prompt(self, name: str) -> Prompt | None:
- """Get prompt by name with all transforms applied (provider + server)."""
+ async def _source_get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
+ """Get prompt by name with all transforms applied (provider + server).
+
+ Args:
+ name: The prompt name.
+ version: Optional version filter. If None, returns highest version.
+ """
root = self._get_root_provider()
- async def base(n: str) -> Prompt | None:
- return await root.get_prompt(n)
+ async def base(n: str, version: VersionSpec | None = None) -> Prompt | None:
+ return await root._get_prompt(n, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_prompt, call_next=chain)
- return await chain(name)
+ return await chain(name, version=version)
async def _source_get_tasks(self) -> Sequence[FastMCPComponent]:
"""Get tasks with all transforms applied (provider + server).
@@ -1049,7 +1096,7 @@ class FastMCP(Generic[LifespanResultT]):
"""Enable components by removing from blocklist, or set allowlist with only=True.
Args:
- keys: Keys to enable (e.g., ``"tool:my_tool"``).
+ keys: Keys to enable (e.g., ``"tool:my_tool@"`` for unversioned, ``"tool:my_tool@1.0"`` for versioned).
tags: Tags to enable - components with these tags will be enabled.
only: If True, switches to allowlist mode - ONLY show these keys/tags.
This clears existing allowlists and sets default visibility to False.
@@ -1064,7 +1111,7 @@ class FastMCP(Generic[LifespanResultT]):
.. code-block:: python
# By key (prefixed)
- server.enable(keys=["tool:my_tool"])
+ server.enable(keys=["tool:my_tool@"])
# By tag
server.enable(tags={"internal"})
@@ -1083,7 +1130,7 @@ class FastMCP(Generic[LifespanResultT]):
"""Disable components by adding to the blocklist.
Args:
- keys: Keys to disable (e.g., ``"tool:my_tool"``).
+ keys: Keys to disable (e.g., ``"tool:my_tool@"`` for unversioned, ``"tool:my_tool@1.0"`` for versioned).
tags: Tags to disable - components with these tags will be disabled.
Note:
@@ -1096,7 +1143,7 @@ class FastMCP(Generic[LifespanResultT]):
.. code-block:: python
# By key (prefixed)
- server.disable(keys=["tool:my_tool"])
+ server.disable(keys=["tool:my_tool@"])
# By tag
server.disable(tags={"dangerous", "internal"})
@@ -1139,11 +1186,9 @@ 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 key (first wins) and apply authorization checks
- seen: dict[str, Tool] = {}
+ # Deduplicate by name (keeping highest version) and apply authorization checks
+ by_name: dict[str, Tool] = {}
for tool in tools:
- if tool.key in seen:
- continue
# Check tool-level auth (skip for STDIO)
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
@@ -1153,25 +1198,54 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[tool.key] = tool
- return list(seen.values())
+ # 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())
- async def get_tool(self, name: str) -> Tool:
+ async def get_tool(
+ self, name: str, version: VersionSpec | str | None = None
+ ) -> Tool:
"""Get an enabled tool by name.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
+
+ Args:
+ name: The tool name.
+ version: Version filter. Can be:
+ - None: returns highest version
+ - str: returns exact version match
+ - VersionSpec: returns best match within spec (highest matching)
"""
- tool = await self._source_get_tool(name)
+ # Convert string to VersionSpec for backward compatibility
+ if isinstance(version, str):
+ version_spec: VersionSpec | None = VersionSpec(eq=version)
+ else:
+ version_spec = version
+
+ tool = await self._source_get_tool(name, version_spec)
+
if tool is None:
- raise NotFoundError(f"Unknown tool: {name!r}")
+ if version is None:
+ raise NotFoundError(f"Unknown tool: {name!r}")
+ elif isinstance(version, str):
+ raise NotFoundError(f"Unknown tool: {name!r} version {version!r}")
+ else:
+ raise NotFoundError(f"Unknown tool: {name!r} matching {version!r}")
# Check tool-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
if not run_auth_checks(tool.auth, ctx):
- raise NotFoundError(f"Unknown tool: {name!r}")
+ if version is None:
+ raise NotFoundError(f"Unknown tool: {name!r}")
+ elif isinstance(version, str):
+ raise NotFoundError(f"Unknown tool: {name!r} version {version!r}")
+ else:
+ raise NotFoundError(f"Unknown tool: {name!r} matching {version!r}")
return tool
@@ -1209,11 +1283,9 @@ 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 key (first wins) and apply authorization checks
- seen: dict[str, Resource] = {}
+ # Deduplicate by URI (keeping highest version) and apply authorization checks
+ by_uri: dict[str, Resource] = {}
for resource in resources:
- if resource.key in seen:
- continue
# Check resource-level auth (skip for STDIO)
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
@@ -1223,25 +1295,57 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[resource.key] = resource
- return list(seen.values())
+ # 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())
- async def get_resource(self, uri: str) -> Resource:
+ async def get_resource(
+ self, uri: str, version: VersionSpec | str | None = None
+ ) -> Resource:
"""Get an enabled resource by URI.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
+
+ Args:
+ uri: The resource URI.
+ version: Version filter. Can be:
+ - None: returns highest version
+ - str: returns exact version match
+ - VersionSpec: returns best match within spec (highest matching)
"""
- resource = await self._source_get_resource(uri)
+ # Convert string to VersionSpec for backward compatibility
+ if isinstance(version, str):
+ version_spec: VersionSpec | None = VersionSpec(eq=version)
+ else:
+ version_spec = version
+
+ resource = await self._source_get_resource(uri, version_spec)
+
if resource is None:
- raise NotFoundError(f"Unknown resource: {uri}")
+ if version is None:
+ raise NotFoundError(f"Unknown resource: {uri}")
+ elif isinstance(version, str):
+ raise NotFoundError(f"Unknown resource: {uri} version {version!r}")
+ else:
+ raise NotFoundError(f"Unknown resource: {uri} matching {version!r}")
# Check resource-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
if not run_auth_checks(resource.auth, ctx):
- raise NotFoundError(f"Unknown resource: {uri}")
+ if version is None:
+ raise NotFoundError(f"Unknown resource: {uri}")
+ elif isinstance(version, str):
+ raise NotFoundError(f"Unknown resource: {uri} version {version!r}")
+ else:
+ raise NotFoundError(f"Unknown resource: {uri} matching {version!r}")
return resource
@@ -1281,11 +1385,9 @@ 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 key (first wins) and apply authorization checks
- seen: dict[str, ResourceTemplate] = {}
+ # Deduplicate by uri_template (keeping highest version) and apply authorization checks
+ by_uri_template: dict[str, ResourceTemplate] = {}
for template in templates:
- if template.key in seen:
- continue
# Check template-level auth (skip for STDIO)
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
@@ -1295,25 +1397,64 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[template.key] = template
- return list(seen.values())
+ # 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())
- async def get_resource_template(self, uri: str) -> ResourceTemplate:
+ async def get_resource_template(
+ self, uri: str, version: VersionSpec | str | None = None
+ ) -> ResourceTemplate:
"""Get an enabled resource template that matches the given URI.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
+
+ Args:
+ uri: The template URI to match.
+ version: Version filter. Can be:
+ - None: returns highest version
+ - str: returns exact version match
+ - VersionSpec: returns best match within spec (highest matching)
"""
- template = await self._source_get_resource_template(uri)
+ # Convert string to VersionSpec for backward compatibility
+ if isinstance(version, str):
+ version_spec: VersionSpec | None = VersionSpec(eq=version)
+ else:
+ version_spec = version
+
+ template = await self._source_get_resource_template(uri, version_spec)
+
if template is None:
- raise NotFoundError(f"Unknown resource template: {uri}")
+ if version is None:
+ raise NotFoundError(f"Unknown resource template: {uri}")
+ elif isinstance(version, str):
+ raise NotFoundError(
+ f"Unknown resource template: {uri} version {version!r}"
+ )
+ else:
+ raise NotFoundError(
+ f"Unknown resource template: {uri} matching {version!r}"
+ )
# Check template-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
if not run_auth_checks(template.auth, ctx):
- raise NotFoundError(f"Unknown resource template: {uri}")
+ if version is None:
+ raise NotFoundError(f"Unknown resource template: {uri}")
+ elif isinstance(version, str):
+ raise NotFoundError(
+ f"Unknown resource template: {uri} version {version!r}"
+ )
+ else:
+ raise NotFoundError(
+ f"Unknown resource template: {uri} matching {version!r}"
+ )
return template
@@ -1351,11 +1492,9 @@ 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 key (first wins) and apply authorization checks
- seen: dict[str, Prompt] = {}
+ # Deduplicate by name (keeping highest version) and apply authorization checks
+ by_name: dict[str, Prompt] = {}
for prompt in prompts:
- if prompt.key in seen:
- continue
# Check prompt-level auth (skip for STDIO)
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
@@ -1365,55 +1504,61 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[prompt.key] = prompt
- return list(seen.values())
+ # 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())
- async def get_prompt(self, name: str) -> Prompt:
+ async def get_prompt(
+ self, name: str, version: VersionSpec | str | None = None
+ ) -> Prompt:
"""Get an enabled prompt by name.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
+
+ Args:
+ name: The prompt name.
+ version: Version filter. Can be:
+ - None: returns highest version
+ - str: returns exact version match
+ - VersionSpec: returns best match within spec (highest matching)
"""
- prompt = await self._source_get_prompt(name)
+ # Convert string to VersionSpec for backward compatibility
+ if isinstance(version, str):
+ version_spec: VersionSpec | None = VersionSpec(eq=version)
+ else:
+ version_spec = version
+
+ prompt = await self._source_get_prompt(name, version_spec)
+
if prompt is None:
- raise NotFoundError(f"Unknown prompt: {name}")
+ if version is None:
+ raise NotFoundError(f"Unknown prompt: {name}")
+ elif isinstance(version, str):
+ raise NotFoundError(f"Unknown prompt: {name!r} version {version!r}")
+ else:
+ raise NotFoundError(f"Unknown prompt: {name!r} matching {version!r}")
# Check prompt-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
if not run_auth_checks(prompt.auth, ctx):
- raise NotFoundError(f"Unknown prompt: {name}")
+ if version is None:
+ raise NotFoundError(f"Unknown prompt: {name}")
+ elif isinstance(version, str):
+ raise NotFoundError(f"Unknown prompt: {name!r} version {version!r}")
+ else:
+ raise NotFoundError(
+ f"Unknown prompt: {name!r} matching {version!r}"
+ )
return prompt
- async def get_component(
- self, key: str
- ) -> Tool | Resource | ResourceTemplate | Prompt:
- """Get a component by its prefixed key.
-
- Routes to the appropriate get_* method which applies server-level layers.
-
- Args:
- key: The prefixed key (e.g., "tool:name", "resource:uri", "template:uri").
-
- Returns:
- The component if found.
-
- Raises:
- NotFoundError: If no component is found with the given key.
- """
- # Parse key and delegate to specific methods which apply layers
- if key.startswith("tool:"):
- return await self.get_tool(key[5:])
- elif key.startswith("resource:"):
- return await self.get_resource(key[9:])
- elif key.startswith("template:"):
- return await self.get_resource_template(key[9:])
- elif key.startswith("prompt:"):
- return await self.get_prompt(key[7:])
- raise NotFoundError(f"Unknown component: {key}")
-
@overload
async def call_tool(
self,
@@ -1997,19 +2142,24 @@ class FastMCP(Generic[LifespanResultT]):
"""
return self._local_provider.add_tool(tool)
- def remove_tool(self, name: str) -> None:
- """Remove a tool from the server.
+ def remove_tool(self, name: str, version: str | None = None) -> None:
+ """Remove tool(s) from the server.
Args:
- name: The name of the tool to remove
+ name: The name of the tool to remove.
+ version: If None, removes ALL versions. If specified, removes only that version.
Raises:
- NotFoundError: If the tool is not found
+ NotFoundError: If no matching tool is found.
"""
try:
- self._local_provider.remove_tool(name)
+ self._local_provider.remove_tool(name, version)
except KeyError:
- raise NotFoundError(f"Tool {name!r} not found") from None
+ if version is None:
+ raise NotFoundError(f"Tool {name!r} not found") from None
+ raise NotFoundError(
+ f"Tool {name!r} version {version!r} not found"
+ ) from None
@overload
def tool(
@@ -2017,6 +2167,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: AnyFunction,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -2036,6 +2187,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -2054,6 +2206,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -2122,6 +2275,7 @@ class FastMCP(Generic[LifespanResultT]):
result = self._local_provider.tool(
name_or_fn,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -2167,6 +2321,7 @@ class FastMCP(Generic[LifespanResultT]):
uri: str,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -2232,6 +2387,7 @@ class FastMCP(Generic[LifespanResultT]):
inner_decorator = self._local_provider.resource(
uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -2265,6 +2421,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: AnyFunction,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -2280,6 +2437,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -2294,6 +2452,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@@ -2378,6 +2537,7 @@ class FastMCP(Generic[LifespanResultT]):
return self._local_provider.prompt(
name_or_fn,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py
index 4cf4b6f68..ff23f2f6c 100644
--- a/src/fastmcp/server/tasks/requests.py
+++ b/src/fastmcp/server/tasks/requests.py
@@ -24,6 +24,7 @@ from mcp.types import (
)
import fastmcp.server.context
+from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
@@ -47,6 +48,25 @@ DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
}
+def _parse_key_version(key_suffix: str) -> tuple[str, str | None]:
+ """Parse a key suffix into (name_or_uri, version).
+
+ Keys always contain @ as a version delimiter (sentinel pattern):
+ - "add@1.0" → ("add", "1.0") # versioned
+ - "add@" → ("add", None) # unversioned
+ - "user@example.com@1.0" → ("user@example.com", "1.0") # @ in URI
+
+ Uses rsplit to split on the LAST @ which is always the version delimiter.
+ Falls back to treating the whole string as the name if @ is not present
+ (for backwards compatibility with legacy task keys).
+ """
+ if "@" not in key_suffix:
+ # Legacy key without version sentinel - treat as unversioned
+ return key_suffix, None
+ name_or_uri, version = key_suffix.rsplit("@", 1)
+ return name_or_uri, version if version else None
+
+
async def _lookup_task_execution(
docket: Any,
session_id: str,
@@ -289,8 +309,31 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
key_parts = parse_task_key(task_key)
component_key = key_parts["component_identifier"]
- # Look up component by its prefixed key
- component = await server.get_component(component_key)
+ # Look up component by its prefixed key (inlined from deleted get_component)
+ component: Tool | Resource | ResourceTemplate | Prompt | None = None
+ try:
+ if component_key.startswith("tool:"):
+ name, version = _parse_key_version(component_key[5:])
+ component = await server.get_tool(name, version)
+ elif component_key.startswith("resource:"):
+ uri, version = _parse_key_version(component_key[9:])
+ component = await server.get_resource(uri, version)
+ elif component_key.startswith("template:"):
+ uri, version = _parse_key_version(component_key[9:])
+ component = await server.get_resource_template(uri, version)
+ elif component_key.startswith("prompt:"):
+ name, version = _parse_key_version(component_key[7:])
+ component = await server.get_prompt(name, version)
+ except NotFoundError:
+ component = None
+
+ if component is None:
+ raise McpError(
+ ErrorData(
+ code=INTERNAL_ERROR,
+ message=f"Component not found for task: {component_key}",
+ )
+ )
# Build related-task metadata
related_task_meta = {
diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py
index 5c1de5d34..2afa3610b 100644
--- a/src/fastmcp/server/transforms/__init__.py
+++ b/src/fastmcp/server/transforms/__init__.py
@@ -21,7 +21,9 @@ Example:
from __future__ import annotations
from collections.abc import Awaitable, Callable, Sequence
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Protocol
+
+from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@@ -30,17 +32,44 @@ if TYPE_CHECKING:
from fastmcp.tools.tool import Tool
# Type aliases for call_next signatures
+# List methods are simple callables
ListToolsNext = Callable[[], Awaitable[Sequence["Tool"]]]
-GetToolNext = Callable[[str], Awaitable["Tool | None"]]
-
ListResourcesNext = Callable[[], Awaitable[Sequence["Resource"]]]
-GetResourceNext = Callable[[str], Awaitable["Resource | None"]]
-
ListResourceTemplatesNext = Callable[[], Awaitable[Sequence["ResourceTemplate"]]]
-GetResourceTemplateNext = Callable[[str], Awaitable["ResourceTemplate | None"]]
-
ListPromptsNext = Callable[[], Awaitable[Sequence["Prompt"]]]
-GetPromptNext = Callable[[str], Awaitable["Prompt | None"]]
+
+
+# Get methods use Protocol to express keyword-only version parameter
+class GetToolNext(Protocol):
+ """Protocol for get_tool call_next functions."""
+
+ def __call__(
+ self, name: str, *, version: VersionSpec | None = None
+ ) -> Awaitable[Tool | None]: ...
+
+
+class GetResourceNext(Protocol):
+ """Protocol for get_resource call_next functions."""
+
+ def __call__(
+ self, uri: str, *, version: VersionSpec | None = None
+ ) -> Awaitable[Resource | None]: ...
+
+
+class GetResourceTemplateNext(Protocol):
+ """Protocol for get_resource_template call_next functions."""
+
+ def __call__(
+ self, uri: str, *, version: VersionSpec | None = None
+ ) -> Awaitable[ResourceTemplate | None]: ...
+
+
+class GetPromptNext(Protocol):
+ """Protocol for get_prompt call_next functions."""
+
+ def __call__(
+ self, name: str, *, version: VersionSpec | None = None
+ ) -> Awaitable[Prompt | None]: ...
class Transform:
@@ -60,9 +89,9 @@ class Transform:
tools = await call_next() # Get tools from downstream
return [transform(t) for t in tools] # Transform them
- async def get_tool(self, name, call_next):
+ async def get_tool(self, name, call_next, *, version=None):
original = self.reverse_name(name) # Map to original name
- tool = await call_next(original) # Get from downstream
+ tool = await call_next(original, version=version) # Get from downstream
return transform(tool) if tool else None
```
"""
@@ -85,17 +114,20 @@ class Transform:
"""
return await call_next()
- async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
+ async def get_tool(
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get a tool by name.
Args:
name: The requested tool name (may be transformed).
call_next: Callable to get tool from downstream.
+ version: Optional version filter to apply.
Returns:
The tool if found, None otherwise.
"""
- return await call_next(name)
+ return await call_next(name, version=version)
# -------------------------------------------------------------------------
# Resources
@@ -113,18 +145,23 @@ class Transform:
return await call_next()
async def get_resource(
- self, uri: str, call_next: GetResourceNext
+ self,
+ uri: str,
+ call_next: GetResourceNext,
+ *,
+ version: VersionSpec | None = None,
) -> Resource | None:
"""Get a resource by URI.
Args:
uri: The requested resource URI (may be transformed).
call_next: Callable to get resource from downstream.
+ version: Optional version filter to apply.
Returns:
The resource if found, None otherwise.
"""
- return await call_next(uri)
+ return await call_next(uri, version=version)
# -------------------------------------------------------------------------
# Resource Templates
@@ -144,18 +181,23 @@ class Transform:
return await call_next()
async def get_resource_template(
- self, uri: str, call_next: GetResourceTemplateNext
+ self,
+ uri: str,
+ call_next: GetResourceTemplateNext,
+ *,
+ version: VersionSpec | None = None,
) -> ResourceTemplate | None:
"""Get a resource template by URI.
Args:
uri: The requested template URI (may be transformed).
call_next: Callable to get template from downstream.
+ version: Optional version filter to apply.
Returns:
The resource template if found, None otherwise.
"""
- return await call_next(uri)
+ return await call_next(uri, version=version)
# -------------------------------------------------------------------------
# Prompts
@@ -172,22 +214,26 @@ class Transform:
"""
return await call_next()
- async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None:
+ async def get_prompt(
+ self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
+ ) -> Prompt | None:
"""Get a prompt by name.
Args:
name: The requested prompt name (may be transformed).
call_next: Callable to get prompt from downstream.
+ version: Optional version filter to apply.
Returns:
The prompt if found, None otherwise.
"""
- return await call_next(name)
+ return await call_next(name, version=version)
# Re-export built-in transforms (must be after Transform class to avoid circular imports)
from fastmcp.server.transforms.namespace import Namespace # noqa: E402
from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402
+from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402
from fastmcp.server.transforms.visibility import Visibility # noqa: E402
__all__ = [
@@ -202,5 +248,7 @@ __all__ = [
"Namespace",
"ToolTransform",
"Transform",
+ "VersionFilter",
+ "VersionSpec",
"Visibility",
]
diff --git a/src/fastmcp/server/transforms/namespace.py b/src/fastmcp/server/transforms/namespace.py
index 007868372..a9e46e418 100644
--- a/src/fastmcp/server/transforms/namespace.py
+++ b/src/fastmcp/server/transforms/namespace.py
@@ -17,6 +17,7 @@ from fastmcp.server.transforms import (
ListToolsNext,
Transform,
)
+from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@@ -104,12 +105,14 @@ class Namespace(Transform):
t.model_copy(update={"name": self._transform_name(t.name)}) for t in tools
]
- async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
+ async def get_tool(
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get tool by namespaced name."""
original = self._reverse_name(name)
if original is None:
return None
- tool = await call_next(original)
+ tool = await call_next(original, version=version)
if tool:
return tool.model_copy(update={"name": name})
return None
@@ -127,13 +130,17 @@ class Namespace(Transform):
]
async def get_resource(
- self, uri: str, call_next: GetResourceNext
+ self,
+ uri: str,
+ call_next: GetResourceNext,
+ *,
+ version: VersionSpec | None = None,
) -> Resource | None:
"""Get resource by namespaced URI."""
original = self._reverse_uri(uri)
if original is None:
return None
- resource = await call_next(original)
+ resource = await call_next(original, version=version)
if resource:
return resource.model_copy(update={"uri": uri})
return None
@@ -153,13 +160,17 @@ class Namespace(Transform):
]
async def get_resource_template(
- self, uri: str, call_next: GetResourceTemplateNext
+ self,
+ uri: str,
+ call_next: GetResourceTemplateNext,
+ *,
+ version: VersionSpec | None = None,
) -> ResourceTemplate | None:
"""Get resource template by namespaced URI."""
original = self._reverse_uri(uri)
if original is None:
return None
- template = await call_next(original)
+ template = await call_next(original, version=version)
if template:
return template.model_copy(
update={"uri_template": self._transform_uri(template.uri_template)}
@@ -177,12 +188,14 @@ class Namespace(Transform):
p.model_copy(update={"name": self._transform_name(p.name)}) for p in prompts
]
- async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None:
+ async def get_prompt(
+ self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
+ ) -> Prompt | None:
"""Get prompt by namespaced name."""
original = self._reverse_name(name)
if original is None:
return None
- prompt = await call_next(original)
+ prompt = await call_next(original, version=version)
if prompt:
return prompt.model_copy(update={"name": name})
return None
diff --git a/src/fastmcp/server/transforms/tool_transform.py b/src/fastmcp/server/transforms/tool_transform.py
index e5b95531e..34c4b10f4 100644
--- a/src/fastmcp/server/transforms/tool_transform.py
+++ b/src/fastmcp/server/transforms/tool_transform.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from fastmcp.server.transforms import GetToolNext, ListToolsNext, Transform
from fastmcp.tools.tool_transform import ToolTransformConfig
+from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.tools.tool import Tool
@@ -72,13 +73,15 @@ class ToolTransform(Transform):
result.append(tool)
return result
- async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
+ async def get_tool(
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get tool by transformed name."""
# Check if this name is a transformed name
original_name = self._name_reverse.get(name, name)
# Get the original tool
- tool = await call_next(original_name)
+ tool = await call_next(original_name, version=version)
if tool is None:
return None
diff --git a/src/fastmcp/server/transforms/version_filter.py b/src/fastmcp/server/transforms/version_filter.py
new file mode 100644
index 000000000..9837d1d73
--- /dev/null
+++ b/src/fastmcp/server/transforms/version_filter.py
@@ -0,0 +1,132 @@
+"""Version filter transform for filtering components by version range."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import TYPE_CHECKING
+
+from fastmcp.server.transforms import (
+ GetPromptNext,
+ GetResourceNext,
+ GetResourceTemplateNext,
+ GetToolNext,
+ ListPromptsNext,
+ ListResourcesNext,
+ ListResourceTemplatesNext,
+ ListToolsNext,
+ Transform,
+)
+from fastmcp.utilities.versions import VersionSpec
+
+if TYPE_CHECKING:
+ from fastmcp.prompts.prompt import Prompt
+ from fastmcp.resources.resource import Resource
+ from fastmcp.resources.template import ResourceTemplate
+ from fastmcp.tools.tool import Tool
+
+
+class VersionFilter(Transform):
+ """Filters components by version range.
+
+ When applied to a provider or server, only components within the version
+ range are visible. Within that filtered set, the highest version of each
+ component is exposed to clients (standard deduplication behavior).
+
+ Parameters mirror comparison operators for clarity:
+
+ # Versions < 3.0 (v1 and v2)
+ server.add_transform(VersionFilter(version_lt="3.0"))
+
+ # Versions >= 2.0 and < 3.0 (only v2.x)
+ server.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0"))
+
+ Works with any version string - PEP 440 (1.0, 2.0) or dates (2025-01-01).
+
+ Args:
+ version_gte: Versions >= this value pass through.
+ version_lt: Versions < this value pass through.
+ """
+
+ def __init__(
+ self,
+ *,
+ version_gte: str | None = None,
+ version_lt: str | None = None,
+ ) -> None:
+ if version_gte is None and version_lt is None:
+ raise ValueError(
+ "At least one of version_gte or version_lt must be specified"
+ )
+ self.version_gte = version_gte
+ self.version_lt = version_lt
+ self._spec = VersionSpec(gte=version_gte, lt=version_lt)
+
+ def __repr__(self) -> str:
+ parts = []
+ if self.version_gte:
+ parts.append(f"version_gte={self.version_gte!r}")
+ if self.version_lt:
+ parts.append(f"version_lt={self.version_lt!r}")
+ return f"VersionFilter({', '.join(parts)})"
+
+ # -------------------------------------------------------------------------
+ # Tools
+ # -------------------------------------------------------------------------
+
+ async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]:
+ tools = await call_next()
+ return [t for t in tools if self._spec.matches(t.version)]
+
+ async def get_tool(
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
+ ) -> Tool | None:
+ return await call_next(name, version=self._spec.intersect(version))
+
+ # -------------------------------------------------------------------------
+ # Resources
+ # -------------------------------------------------------------------------
+
+ async def list_resources(self, call_next: ListResourcesNext) -> Sequence[Resource]:
+ resources = await call_next()
+ return [r for r in resources if self._spec.matches(r.version)]
+
+ async def get_resource(
+ self,
+ uri: str,
+ call_next: GetResourceNext,
+ *,
+ version: VersionSpec | None = None,
+ ) -> Resource | None:
+ return await call_next(uri, version=self._spec.intersect(version))
+
+ # -------------------------------------------------------------------------
+ # Resource Templates
+ # -------------------------------------------------------------------------
+
+ async def list_resource_templates(
+ self, call_next: ListResourceTemplatesNext
+ ) -> Sequence[ResourceTemplate]:
+ templates = await call_next()
+ return [t for t in templates if self._spec.matches(t.version)]
+
+ async def get_resource_template(
+ self,
+ uri: str,
+ call_next: GetResourceTemplateNext,
+ *,
+ version: VersionSpec | None = None,
+ ) -> ResourceTemplate | None:
+ return await call_next(uri, version=self._spec.intersect(version))
+
+ # -------------------------------------------------------------------------
+ # Prompts
+ # -------------------------------------------------------------------------
+
+ async def list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt]:
+ prompts = await call_next()
+ return [p for p in prompts if self._spec.matches(p.version)]
+
+ async def get_prompt(
+ self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
+ ) -> Prompt | None:
+ return await call_next(name, version=self._spec.intersect(version))
diff --git a/src/fastmcp/server/transforms/visibility.py b/src/fastmcp/server/transforms/visibility.py
index c13aabc14..3faa3721b 100644
--- a/src/fastmcp/server/transforms/visibility.py
+++ b/src/fastmcp/server/transforms/visibility.py
@@ -23,6 +23,7 @@ from fastmcp.server.transforms import (
ListToolsNext,
Transform,
)
+from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@@ -60,7 +61,7 @@ class Visibility(Transform):
Example:
```python
visibility = Visibility()
- visibility.disable(keys=["tool:secret"])
+ visibility.disable(keys=["tool:secret@"])
# Now visibility filters out the "secret" tool
```
"""
@@ -125,7 +126,7 @@ class Visibility(Transform):
"""Add to blocklist (hide components).
Args:
- keys: Component keys to hide (e.g., "tool:my_tool", "resource:file://x")
+ keys: Component keys to hide (e.g., "tool:my_tool@", "resource:file://x@")
tags: Tags to hide - any component with these tags will be hidden
"""
notifications: set[type[mcp.types.ServerNotificationType]] = set()
@@ -242,9 +243,11 @@ class Visibility(Transform):
tools = await call_next()
return [t for t in tools if self.is_enabled(t)]
- async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
+ async def get_tool(
+ self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
+ ) -> Tool | None:
"""Get tool if enabled, None otherwise."""
- tool = await call_next(name)
+ tool = await call_next(name, version=version)
if tool is None or not self.is_enabled(tool):
return None
return tool
@@ -259,10 +262,14 @@ class Visibility(Transform):
return [r for r in resources if self.is_enabled(r)]
async def get_resource(
- self, uri: str, call_next: GetResourceNext
+ self,
+ uri: str,
+ call_next: GetResourceNext,
+ *,
+ version: VersionSpec | None = None,
) -> Resource | None:
"""Get resource if enabled, None otherwise."""
- resource = await call_next(uri)
+ resource = await call_next(uri, version=version)
if resource is None or not self.is_enabled(resource):
return None
return resource
@@ -279,10 +286,14 @@ class Visibility(Transform):
return [t for t in templates if self.is_enabled(t)]
async def get_resource_template(
- self, uri: str, call_next: GetResourceTemplateNext
+ self,
+ uri: str,
+ call_next: GetResourceTemplateNext,
+ *,
+ version: VersionSpec | None = None,
) -> ResourceTemplate | None:
"""Get resource template if enabled, None otherwise."""
- template = await call_next(uri)
+ template = await call_next(uri, version=version)
if template is None or not self.is_enabled(template):
return None
return template
@@ -296,9 +307,11 @@ class Visibility(Transform):
prompts = await call_next()
return [p for p in prompts if self.is_enabled(p)]
- async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None:
+ async def get_prompt(
+ self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
+ ) -> Prompt | None:
"""Get prompt if enabled, None otherwise."""
- prompt = await call_next(name)
+ prompt = await call_next(name, version=version)
if prompt is None or not self.is_enabled(prompt):
return None
return prompt
diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py
index 09ce689f2..890841ce8 100644
--- a/src/fastmcp/tools/function_tool.py
+++ b/src/fastmcp/tools/function_tool.py
@@ -64,6 +64,7 @@ class ToolMeta:
type: Literal["tool"] = field(default="tool", init=False)
name: str | None = None
+ version: str | int | None = None
title: str | None = None
description: str | None = None
icons: list[Icon] | None = None
@@ -111,6 +112,7 @@ class FunctionTool(Tool):
metadata: ToolMeta | None = None,
# Keep individual params for backwards compat
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -139,6 +141,7 @@ class FunctionTool(Tool):
x is not None and x is not NotSet
for x in [
name,
+ version,
title,
description,
icons,
@@ -165,6 +168,7 @@ class FunctionTool(Tool):
if metadata is None:
metadata = ToolMeta(
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -228,6 +232,7 @@ class FunctionTool(Tool):
return cls(
fn=parsed_fn.fn,
name=metadata.name or parsed_fn.name,
+ version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
description=metadata.description or parsed_fn.description,
icons=metadata.icons,
@@ -329,6 +334,7 @@ def tool(fn: F) -> F: ...
def tool(
name_or_fn: str,
*,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -347,6 +353,7 @@ def tool(
name_or_fn: None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -366,6 +373,7 @@ def tool(
name_or_fn: str | Callable[..., Any] | None = None,
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -397,6 +405,7 @@ def tool(
# Create metadata first, then pass it
tool_meta = ToolMeta(
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -415,6 +424,7 @@ def tool(
def attach_metadata(fn: F, tool_name: str | None) -> F:
metadata = ToolMeta(
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 608e345c2..f4b83247c 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -196,6 +196,7 @@ class Tool(FastMCPComponent):
fn: Callable[..., Any],
*,
name: str | None = None,
+ version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@@ -215,6 +216,7 @@ class Tool(FastMCPComponent):
return FunctionTool.from_function(
fn=fn,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py
index 1a97ea444..fbe37a8bb 100644
--- a/src/fastmcp/tools/tool_transform.py
+++ b/src/fastmcp/tools/tool_transform.py
@@ -367,6 +367,7 @@ class TransformedTool(Tool):
cls,
tool: Tool,
name: str | None = None,
+ version: str | NotSetT | None = NotSet,
title: str | NotSetT | None = NotSet,
description: str | NotSetT | None = NotSet,
tags: set[str] | None = None,
@@ -385,6 +386,7 @@ class TransformedTool(Tool):
to call the parent tool. Functions with **kwargs receive transformed
argument names.
name: New name for the tool. Defaults to parent tool's name.
+ version: New version for the tool. Defaults to parent tool's version.
title: New title for the tool. Defaults to parent tool's title.
transform_args: Optional transformations for parent tool arguments.
Only specified arguments are transformed, others pass through unchanged:
@@ -569,6 +571,7 @@ class TransformedTool(Tool):
)
final_name = name or tool.name
+ final_version = version if not isinstance(version, NotSetT) else tool.version
final_description = (
description if not isinstance(description, NotSetT) else tool.description
)
@@ -586,6 +589,7 @@ class TransformedTool(Tool):
forwarding_fn=forwarding_fn,
parent_tool=tool,
name=final_name,
+ version=final_version,
title=final_title,
description=final_description,
parameters=final_schema,
@@ -892,7 +896,9 @@ class ToolTransformConfig(FastMCPBaseModel):
"""Provides a way to transform a tool."""
name: str | None = Field(default=None, description="The new name for the tool.")
-
+ version: str | None = Field(
+ default=None, description="The new version for the tool."
+ )
title: str | None = Field(
default=None,
description="The new title of the tool.",
diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py
index af083dd95..b9a7f2eab 100644
--- a/src/fastmcp/utilities/components.py
+++ b/src/fastmcp/utilities/components.py
@@ -31,6 +31,21 @@ def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]:
return set(maybe_set)
+def _coerce_version(v: str | int | None) -> str | None:
+ """Coerce version to string, accepting int or str.
+
+ Raises ValueError if version contains '@' (used as key delimiter).
+ """
+ if v is None:
+ return None
+ version = str(v)
+ if "@" in version:
+ raise ValueError(
+ f"Version string cannot contain '@' (used as key delimiter): {version!r}"
+ )
+ return version
+
+
class FastMCPComponent(FastMCPBaseModel):
"""Base class for FastMCP tools, prompts, resources, and resource templates."""
@@ -52,6 +67,11 @@ class FastMCPComponent(FastMCPBaseModel):
name: str = Field(
description="The name of the component.",
)
+ version: Annotated[str | None, BeforeValidator(_coerce_version)] = Field(
+ default=None,
+ description="Optional version identifier for this component. "
+ "Multiple versions of the same component (same name) can coexist.",
+ )
title: str | None = Field(
default=None,
description="The title of the component for display purposes.",
@@ -94,12 +114,17 @@ class FastMCPComponent(FastMCPBaseModel):
def key(self) -> str:
"""The globally unique lookup key for this component.
- Format: "{key_prefix}:{identifier}" e.g. "tool:my_tool", "resource:file://x.txt"
+ Format: "{key_prefix}:{identifier}@{version}" or "{key_prefix}:{identifier}@"
+ e.g. "tool:my_tool@v2", "tool:my_tool@", "resource:file://x.txt@"
+
+ The @ suffix is ALWAYS present to enable unambiguous parsing of keys
+ (URIs may contain @ characters, so we always include the delimiter).
Subclasses should override this to use their specific identifier.
Base implementation uses name.
"""
- return self.make_key(self.name)
+ base_key = self.make_key(self.name)
+ return f"{base_key}@{self.version or ''}"
def get_meta(
self, include_fastmcp_meta: bool | None = None
@@ -133,7 +158,17 @@ class FastMCPComponent(FastMCPBaseModel):
return self.model_dump() == other.model_dump()
def __repr__(self) -> str:
- return f"{self.__class__.__name__}(name={self.name!r}, title={self.title!r}, description={self.description!r}, tags={self.tags})"
+ parts = [f"name={self.name!r}"]
+ if self.version:
+ parts.append(f"version={self.version!r}")
+ parts.extend(
+ [
+ f"title={self.title!r}",
+ f"description={self.description!r}",
+ f"tags={self.tags}",
+ ]
+ )
+ return f"{self.__class__.__name__}({', '.join(parts)})"
def enable(self) -> None:
"""Removed in 3.0. Use server.enable(keys=[...]) instead."""
diff --git a/src/fastmcp/utilities/versions.py b/src/fastmcp/utilities/versions.py
new file mode 100644
index 000000000..42c2c480b
--- /dev/null
+++ b/src/fastmcp/utilities/versions.py
@@ -0,0 +1,282 @@
+"""Version comparison utilities for component versioning.
+
+This module provides utilities for comparing component versions. Versions are
+strings that are first attempted to be parsed as PEP 440 versions (using the
+`packaging` library), falling back to lexicographic string comparison.
+
+Examples:
+ - "1", "2", "10" → parsed as PEP 440, compared semantically (1 < 2 < 10)
+ - "1.0", "2.0" → parsed as PEP 440
+ - "v1.0" → 'v' prefix stripped, parsed as "1.0"
+ - "2025-01-15" → not valid PEP 440, compared as strings
+ - None → sorts lowest (unversioned components)
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import total_ordering
+from typing import TYPE_CHECKING
+
+from packaging.version import InvalidVersion, Version
+
+if TYPE_CHECKING:
+ from fastmcp.utilities.components import FastMCPComponent
+
+
+@dataclass
+class VersionSpec:
+ """Specification for filtering components by version.
+
+ Used by transforms and providers to filter components to a specific
+ version or version range. Unversioned components (version=None) always
+ match any spec.
+
+ Args:
+ gte: If set, only versions >= this value match.
+ lt: If set, only versions < this value match.
+ eq: If set, only this exact version matches (gte/lt ignored).
+ """
+
+ gte: str | None = None
+ lt: str | None = None
+ eq: str | None = None
+
+ def matches(self, version: str | None) -> bool:
+ """Check if a version matches this spec.
+
+ Args:
+ version: The version to check, or None for unversioned.
+
+ Returns:
+ True if the version matches the spec.
+ """
+ if version is None:
+ # Unversioned components always match
+ return True
+
+ if self.eq is not None:
+ return version == self.eq
+
+ key = parse_version_key(version)
+
+ if self.gte is not None:
+ gte_key = parse_version_key(self.gte)
+ if key < gte_key:
+ return False
+
+ if self.lt is not None:
+ lt_key = parse_version_key(self.lt)
+ if not key < lt_key:
+ return False
+
+ return True
+
+ def intersect(self, other: VersionSpec | None) -> VersionSpec:
+ """Return a spec that satisfies both this spec and other.
+
+ Used by transforms to combine caller constraints with filter constraints.
+ For example, if a VersionFilter has lt="3.0" and caller requests eq="1.0",
+ the intersection validates "1.0" is in range and returns the exact spec.
+
+ Args:
+ other: Another spec to intersect with, or None.
+
+ Returns:
+ A VersionSpec that matches only versions satisfying both specs.
+ """
+ if other is None:
+ return self
+
+ if self.eq is not None:
+ # This spec wants exact - validate against other's range
+ if other.matches(self.eq):
+ return self
+ return VersionSpec(eq="__impossible__")
+
+ if other.eq is not None:
+ # Other wants exact - validate against our range
+ if self.matches(other.eq):
+ return other
+ return VersionSpec(eq="__impossible__")
+
+ # Both are ranges - take tighter bounds
+ return VersionSpec(
+ gte=max_version(self.gte, other.gte),
+ lt=min_version(self.lt, other.lt),
+ )
+
+
+@total_ordering
+class VersionKey:
+ """A comparable version key that handles None, PEP 440 versions, and strings.
+
+ Comparison order:
+ 1. None (unversioned) sorts lowest
+ 2. PEP 440 versions sort by semantic version order
+ 3. Invalid versions (strings) sort lexicographically
+ 4. When comparing PEP 440 vs string, PEP 440 comes first
+ """
+
+ __slots__ = ("_is_none", "_is_pep440", "_parsed", "_raw")
+
+ def __init__(self, version: str | None) -> None:
+ self._raw = version
+ self._is_none = version is None
+ self._is_pep440 = False
+ self._parsed: Version | str | None = None
+
+ if version is not None:
+ # Strip leading 'v' if present (common convention like "v1.0")
+ normalized = version.lstrip("v") if version.startswith("v") else version
+ try:
+ self._parsed = Version(normalized)
+ self._is_pep440 = True
+ except InvalidVersion:
+ # Fall back to string comparison for non-PEP 440 versions
+ self._parsed = version
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, VersionKey):
+ return NotImplemented
+ if self._is_none and other._is_none:
+ return True
+ if self._is_none != other._is_none:
+ return False
+ # Both are not None
+ if self._is_pep440 and other._is_pep440:
+ return self._parsed == other._parsed
+ if not self._is_pep440 and not other._is_pep440:
+ return self._parsed == other._parsed
+ # One is PEP 440, other is string - never equal
+ return False
+
+ def __lt__(self, other: object) -> bool:
+ if not isinstance(other, VersionKey):
+ return NotImplemented
+ # None sorts lowest
+ if self._is_none and other._is_none:
+ return False # Equal
+ if self._is_none:
+ return True # None < anything
+ if other._is_none:
+ return False # anything > None
+
+ # Both are not None
+ if self._is_pep440 and other._is_pep440:
+ # Both PEP 440 - compare normally
+ assert isinstance(self._parsed, Version)
+ assert isinstance(other._parsed, Version)
+ return self._parsed < other._parsed
+ if not self._is_pep440 and not other._is_pep440:
+ # Both strings - lexicographic
+ assert isinstance(self._parsed, str)
+ assert isinstance(other._parsed, str)
+ return self._parsed < other._parsed
+ # Mixed: PEP 440 sorts before strings
+ # (arbitrary but consistent choice)
+ return self._is_pep440
+
+ def __repr__(self) -> str:
+ return f"VersionKey({self._raw!r})"
+
+
+def parse_version_key(version: str | None) -> VersionKey:
+ """Parse a version string into a sortable key.
+
+ Args:
+ version: The version string, or None for unversioned.
+
+ Returns:
+ A VersionKey suitable for sorting.
+ """
+ return VersionKey(version)
+
+
+def version_sort_key(component: FastMCPComponent) -> VersionKey:
+ """Get a sort key for a component based on its version.
+
+ Use with sorted() or max() to order components by version.
+
+ Args:
+ component: The component to get a sort key for.
+
+ Returns:
+ A sortable VersionKey.
+
+ Example:
+ ```python
+ tools = [tool_v1, tool_v2, tool_unversioned]
+ highest = max(tools, key=version_sort_key) # Returns tool_v2
+ ```
+ """
+ return parse_version_key(component.version)
+
+
+def compare_versions(a: str | None, b: str | None) -> int:
+ """Compare two version strings.
+
+ Args:
+ a: First version string (or None).
+ b: Second version string (or None).
+
+ Returns:
+ -1 if a < b, 0 if a == b, 1 if a > b.
+
+ Example:
+ ```python
+ compare_versions("1.0", "2.0") # Returns -1
+ compare_versions("2.0", "1.0") # Returns 1
+ compare_versions(None, "1.0") # Returns -1 (None < any version)
+ ```
+ """
+ key_a = parse_version_key(a)
+ key_b = parse_version_key(b)
+ return (key_a > key_b) - (key_a < key_b)
+
+
+def is_version_greater(a: str | None, b: str | None) -> bool:
+ """Check if version a is greater than version b.
+
+ Args:
+ a: First version string (or None).
+ b: Second version string (or None).
+
+ Returns:
+ True if a > b, False otherwise.
+ """
+ return compare_versions(a, b) > 0
+
+
+def max_version(a: str | None, b: str | None) -> str | None:
+ """Return the greater of two versions.
+
+ Args:
+ a: First version string (or None).
+ b: Second version string (or None).
+
+ Returns:
+ The greater version, or None if both are None.
+ """
+ if a is None:
+ return b
+ if b is None:
+ return a
+ return a if compare_versions(a, b) >= 0 else b
+
+
+def min_version(a: str | None, b: str | None) -> str | None:
+ """Return the lesser of two versions.
+
+ Args:
+ a: First version string (or None).
+ b: Second version string (or None).
+
+ Returns:
+ The lesser version, or None if both are None.
+ """
+ if a is None:
+ return b
+ if b is None:
+ return a
+ return a if compare_versions(a, b) <= 0 else b
diff --git a/tests/client/test_notifications.py b/tests/client/test_notifications.py
index 8267de137..6452168ee 100644
--- a/tests/client/test_notifications.py
+++ b/tests/client/test_notifications.py
@@ -91,7 +91,7 @@ def notification_test_server(recording_message_handler):
"""Enable the target tool."""
# Find and enable the target tool
try:
- ctx.fastmcp.enable(keys=["tool:target_tool"])
+ ctx.fastmcp.enable(keys=["tool:target_tool@"])
return "Target tool enabled"
except Exception:
return "Target tool not found"
@@ -102,7 +102,7 @@ def notification_test_server(recording_message_handler):
"""Disable the target tool."""
# Find and disable the target tool
try:
- ctx.fastmcp.disable(keys=["tool:target_tool"])
+ ctx.fastmcp.disable(keys=["tool:target_tool@"])
return "Target tool disabled"
except Exception:
return "Target tool not found"
@@ -219,7 +219,7 @@ def resource_notification_test_server(recording_message_handler):
def enable_target_resource(ctx: Context) -> str:
"""Enable the target resource."""
try:
- ctx.fastmcp.enable(keys=["resource:resource://target"])
+ ctx.fastmcp.enable(keys=["resource:resource://target@"])
return "Target resource enabled"
except Exception:
return "Target resource not found"
@@ -229,7 +229,7 @@ def resource_notification_test_server(recording_message_handler):
def disable_target_resource(ctx: Context) -> str:
"""Disable the target resource."""
try:
- ctx.fastmcp.disable(keys=["resource:resource://target"])
+ ctx.fastmcp.disable(keys=["resource:resource://target@"])
return "Target resource disabled"
except Exception:
return "Target resource not found"
@@ -302,7 +302,7 @@ def prompt_notification_test_server(recording_message_handler):
def enable_target_prompt(ctx: Context) -> str:
"""Enable the target prompt."""
try:
- ctx.fastmcp.enable(keys=["prompt:target_prompt"])
+ ctx.fastmcp.enable(keys=["prompt:target_prompt@"])
return "Target prompt enabled"
except Exception:
return "Target prompt not found"
@@ -312,7 +312,7 @@ def prompt_notification_test_server(recording_message_handler):
def disable_target_prompt(ctx: Context) -> str:
"""Disable the target prompt."""
try:
- ctx.fastmcp.disable(keys=["prompt:target_prompt"])
+ ctx.fastmcp.disable(keys=["prompt:target_prompt@"])
return "Target prompt disabled"
except Exception:
return "Target prompt not found"
diff --git a/tests/contrib/test_component_manager.py b/tests/contrib/test_component_manager.py
index a7e8f4eb8..c30763bfc 100644
--- a/tests/contrib/test_component_manager.py
+++ b/tests/contrib/test_component_manager.py
@@ -79,7 +79,7 @@ class TestComponentManagementRoutes:
async def test_enable_tool_route(self, client, mcp):
"""Test enabling a tool via the HTTP route."""
# First disable the tool
- mcp.disable(keys=["tool:test_tool"])
+ mcp.disable(keys=["tool:test_tool@"])
tools = await mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@@ -112,7 +112,7 @@ class TestComponentManagementRoutes:
async def test_enable_resource_route(self, client, mcp):
"""Test enabling a resource via the HTTP route."""
# First disable the resource
- mcp.disable(keys=["resource:data://test_resource"])
+ mcp.disable(keys=["resource:data://test_resource@"])
resources = await mcp.get_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@@ -145,7 +145,7 @@ class TestComponentManagementRoutes:
async def test_enable_template_route(self, client, mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://test_resource/{id}"
- mcp.disable(keys=["template:data://test_resource/{id}"])
+ mcp.disable(keys=["template:data://test_resource/{id}@"])
templates = await mcp.get_resource_templates()
assert not any(t.uri_template == key for t in templates)
response = client.post("/resources/data://test_resource/{id}/enable")
@@ -172,7 +172,7 @@ class TestComponentManagementRoutes:
async def test_enable_prompt_route(self, client, mcp):
"""Test enabling a prompt via the HTTP route."""
# First disable the prompt
- mcp.disable(keys=["prompt:test_prompt"])
+ mcp.disable(keys=["prompt:test_prompt@"])
prompts = await mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@@ -205,7 +205,7 @@ class TestComponentManagementRoutes:
async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a tool on a mounted server via the parent server's HTTP route."""
# Disable the tool on the sub-server
- mounted_mcp.disable(keys=["tool:mounted_tool"])
+ mounted_mcp.disable(keys=["tool:mounted_tool@"])
tools = await mounted_mcp.get_tools()
assert not any(t.name == "mounted_tool" for t in tools)
# Enable via parent
@@ -231,7 +231,7 @@ class TestComponentManagementRoutes:
async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
- mounted_mcp.disable(keys=["resource:data://mounted_resource"])
+ mounted_mcp.disable(keys=["resource:data://mounted_resource@"])
resources = await mounted_mcp.get_resources()
assert not any(str(r.uri) == "data://mounted_resource" for r in resources)
response = client.post("/resources/data://sub/mounted_resource/enable")
@@ -257,7 +257,7 @@ class TestComponentManagementRoutes:
async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://mounted_resource/{id}"
- mounted_mcp.disable(keys=["template:data://mounted_resource/{id}"])
+ mounted_mcp.disable(keys=["template:data://mounted_resource/{id}@"])
templates = await mounted_mcp.get_resource_templates()
assert not any(t.uri_template == key for t in templates)
response = client.post("/resources/data://sub/mounted_resource/{id}/enable")
@@ -283,7 +283,7 @@ class TestComponentManagementRoutes:
async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a prompt on a mounted server via the parent server's HTTP route."""
- mounted_mcp.disable(keys=["prompt:mounted_prompt"])
+ mounted_mcp.disable(keys=["prompt:mounted_prompt@"])
prompts = await mounted_mcp.get_prompts()
assert not any(p.name == "mounted_prompt" for p in prompts)
response = client.post("/prompts/sub_mounted_prompt/enable")
@@ -389,7 +389,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_enable_tool(self):
"""Test that unauthenticated requests to enable a tool are rejected."""
- self.mcp.disable(keys=["tool:test_tool"])
+ self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@@ -400,7 +400,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_tool(self):
"""Test that authenticated requests to enable a tool are allowed."""
- self.mcp.disable(keys=["tool:test_tool"])
+ self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@@ -438,7 +438,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_tool(self):
"""Test that requests with insufficient scopes are rejected."""
- self.mcp.disable(keys=["tool:test_tool"])
+ self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@@ -452,7 +452,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_resource(self):
"""Test that authenticated requests to enable a resource are allowed."""
- self.mcp.disable(keys=["resource:data://test_resource"])
+ self.mcp.disable(keys=["resource:data://test_resource@"])
resources = await self.mcp.get_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@@ -477,7 +477,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_resource(self):
"""Test that requests with insufficient scopes are rejected."""
- self.mcp.disable(keys=["resource:data://test_resource"])
+ self.mcp.disable(keys=["resource:data://test_resource@"])
resources = await self.mcp.get_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@@ -505,7 +505,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_enable_prompt(self):
"""Test that unauthenticated requests to enable a prompt are rejected."""
- self.mcp.disable(keys=["prompt:test_prompt"])
+ self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@@ -516,7 +516,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_prompt(self):
"""Test that authenticated requests to enable a prompt are allowed."""
- self.mcp.disable(keys=["prompt:test_prompt"])
+ self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@@ -594,7 +594,7 @@ class TestComponentManagerWithPath:
return TestClient(mcp_with_path.http_app())
async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path):
- mcp_with_path.disable(keys=["tool:test_tool"])
+ mcp_with_path.disable(keys=["tool:test_tool@"])
tools = await mcp_with_path.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = client_with_path.post("/test/tools/test_tool/enable")
@@ -615,7 +615,7 @@ class TestComponentManagerWithPath:
assert not any(str(r.uri) == "data://test_resource" for r in resources)
async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path):
- mcp_with_path.disable(keys=["prompt:test_prompt"])
+ mcp_with_path.disable(keys=["prompt:test_prompt@"])
prompts = await mcp_with_path.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = client_with_path.post("/test/prompts/test_prompt/enable")
@@ -668,7 +668,7 @@ class TestComponentManagerWithPathAuth:
self.client = TestClient(self.mcp.http_app())
async def test_unauthorized_enable_tool(self):
- self.mcp.disable(keys=["tool:test_tool"])
+ self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post("/test/tools/test_tool/enable")
@@ -677,7 +677,7 @@ class TestComponentManagerWithPathAuth:
assert not any(t.name == "test_tool" for t in tools)
async def test_forbidden_enable_tool(self):
- self.mcp.disable(keys=["tool:test_tool"])
+ self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post(
@@ -689,7 +689,7 @@ class TestComponentManagerWithPathAuth:
assert not any(t.name == "test_tool" for t in tools)
async def test_authorized_enable_tool(self):
- self.mcp.disable(keys=["tool:test_tool"])
+ self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post(
@@ -733,7 +733,7 @@ class TestComponentManagerWithPathAuth:
assert not any(str(r.uri) == "data://test_resource" for r in resources)
async def test_unauthorized_enable_prompt(self):
- self.mcp.disable(keys=["prompt:test_prompt"])
+ self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post("/test/prompts/test_prompt/enable")
@@ -742,7 +742,7 @@ class TestComponentManagerWithPathAuth:
assert not any(p.name == "test_prompt" for p in prompts)
async def test_forbidden_enable_prompt(self):
- self.mcp.disable(keys=["prompt:test_prompt"])
+ self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post(
@@ -754,7 +754,7 @@ class TestComponentManagerWithPathAuth:
assert not any(p.name == "test_prompt" for p in prompts)
async def test_authorized_enable_prompt(self):
- self.mcp.disable(keys=["prompt:test_prompt"])
+ self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post(
diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py
index c76e3dd1c..db86669e9 100644
--- a/tests/server/middleware/test_logging.py
+++ b/tests/server/middleware/test_logging.py
@@ -332,7 +332,7 @@ class TestLoggingMiddleware:
assert get_log_lines(caplog) == snapshot(
[
- '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"title\\":null,\\"description\\":null,\\"icons\\":null,\\"tags\\":[],\\"meta\\":null,\\"task_config\\":{\\"mode\\":\\"forbidden\\",\\"poll_interval\\":\\"PT5S\\"},\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}',
+ '{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"version\\":null,\\"title\\":null,\\"description\\":null,\\"icons\\":null,\\"tags\\":[],\\"meta\\":null,\\"task_config\\":{\\"mode\\":\\"forbidden\\",\\"poll_interval\\":\\"PT5S\\"},\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}',
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
]
)
diff --git a/tests/server/providers/test_local_provider.py b/tests/server/providers/test_local_provider.py
index 8f325c395..5c4d10472 100644
--- a/tests/server/providers/test_local_provider.py
+++ b/tests/server/providers/test_local_provider.py
@@ -35,8 +35,8 @@ class TestLocalProviderStorage:
)
provider.add_tool(tool)
- assert "tool:test_tool" in provider._components
- assert provider._components["tool:test_tool"] is tool
+ assert "tool:test_tool@" in provider._components
+ assert provider._components["tool:test_tool@"] is tool
def test_add_multiple_tools(self):
"""Test adding multiple tools."""
@@ -55,8 +55,8 @@ class TestLocalProviderStorage:
provider.add_tool(tool1)
provider.add_tool(tool2)
- assert "tool:tool1" in provider._components
- assert "tool:tool2" in provider._components
+ assert "tool:tool1@" in provider._components
+ assert "tool:tool2@" in provider._components
def test_remove_tool(self):
"""Test removing a tool from LocalProvider."""
@@ -70,7 +70,7 @@ class TestLocalProviderStorage:
provider.add_tool(tool)
provider.remove_tool("test_tool")
- assert "tool:test_tool" not in provider._components
+ assert "tool:test_tool@" not in provider._components
def test_remove_nonexistent_tool_raises(self):
"""Test that removing a nonexistent tool raises KeyError."""
@@ -87,7 +87,7 @@ class TestLocalProviderStorage:
def test_resource() -> str:
return "content"
- assert "resource:resource://test" in provider._components
+ assert "resource:resource://test@" in provider._components
def test_remove_resource(self):
"""Test removing a resource from LocalProvider."""
@@ -99,7 +99,7 @@ class TestLocalProviderStorage:
provider.remove_resource("resource://test")
- assert "resource:resource://test" not in provider._components
+ assert "resource:resource://test@" not in provider._components
def test_add_template(self):
"""Test adding a resource template to LocalProvider."""
@@ -109,7 +109,7 @@ class TestLocalProviderStorage:
def template_fn(id: str) -> str:
return f"Resource {id}"
- assert "template:resource://{id}" in provider._components
+ assert "template:resource://{id}@" in provider._components
def test_remove_template(self):
"""Test removing a resource template from LocalProvider."""
@@ -121,7 +121,7 @@ class TestLocalProviderStorage:
provider.remove_template("resource://{id}")
- assert "template:resource://{id}" not in provider._components
+ assert "template:resource://{id}@" not in provider._components
def test_add_prompt(self):
"""Test adding a prompt to LocalProvider."""
@@ -133,7 +133,7 @@ class TestLocalProviderStorage:
)
provider.add_prompt(prompt)
- assert "prompt:test_prompt" in provider._components
+ assert "prompt:test_prompt@" in provider._components
def test_remove_prompt(self):
"""Test removing a prompt from LocalProvider."""
@@ -146,7 +146,7 @@ class TestLocalProviderStorage:
provider.add_prompt(prompt)
provider.remove_prompt("test_prompt")
- assert "prompt:test_prompt" not in provider._components
+ assert "prompt:test_prompt@" not in provider._components
class TestLocalProviderInterface:
@@ -310,8 +310,8 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
- assert "tool:my_tool" in provider._components
- assert provider._components["tool:my_tool"].name == "my_tool"
+ assert "tool:my_tool@" in provider._components
+ assert provider._components["tool:my_tool@"].name == "my_tool"
def test_tool_decorator_with_custom_name_registers(self):
"""Tool with custom name should register under that name."""
@@ -321,8 +321,8 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
- assert "tool:custom_name" in provider._components
- assert "tool:my_tool" not in provider._components
+ assert "tool:custom_name@" in provider._components
+ assert "tool:my_tool@" not in provider._components
def test_tool_direct_call(self):
"""provider.tool(fn) should register the function."""
@@ -333,7 +333,7 @@ class TestLocalProviderDecorators:
provider.tool(my_tool, name="direct_tool")
- assert "tool:direct_tool" in provider._components
+ assert "tool:direct_tool@" in provider._components
def test_tool_enabled_false(self):
"""Tool with enabled=False should be disabled."""
@@ -343,8 +343,8 @@ class TestLocalProviderDecorators:
def disabled_tool() -> str:
return "should be disabled"
- assert "tool:disabled_tool" in provider._components
- tool = provider._components["tool:disabled_tool"]
+ assert "tool:disabled_tool@" in provider._components
+ tool = provider._components["tool:disabled_tool@"]
assert not provider._is_component_enabled(tool)
async def test_tool_enabled_false_not_listed(self):
@@ -386,7 +386,7 @@ class TestLocalProviderDecorators:
def my_resource() -> str:
return "test content"
- assert "resource:resource://test" in provider._components
+ assert "resource:resource://test@" in provider._components
def test_resource_with_custom_name_registers(self):
"""Resource with custom name should register with that name."""
@@ -396,7 +396,7 @@ class TestLocalProviderDecorators:
def my_resource() -> str:
return "test content"
- assert provider._components["resource:resource://test"].name == "custom_name"
+ assert provider._components["resource:resource://test@"].name == "custom_name"
def test_resource_enabled_false(self):
"""Resource with enabled=False should be disabled."""
@@ -406,8 +406,8 @@ class TestLocalProviderDecorators:
def disabled_resource() -> str:
return "should be disabled"
- assert "resource:resource://test" in provider._components
- resource = provider._components["resource:resource://test"]
+ assert "resource:resource://test@" in provider._components
+ resource = provider._components["resource:resource://test@"]
assert not provider._is_component_enabled(resource)
async def test_resource_enabled_false_not_listed(self):
@@ -435,8 +435,8 @@ class TestLocalProviderDecorators:
def disabled_template(id: str) -> str:
return f"Data {id}"
- assert "template:data://{id}" in provider._components
- template = provider._components["template:data://{id}"]
+ assert "template:data://{id}@" in provider._components
+ template = provider._components["template:data://{id}@"]
assert not provider._is_component_enabled(template)
async def test_template_enabled_false_not_listed(self):
@@ -478,7 +478,7 @@ class TestLocalProviderDecorators:
def my_prompt() -> str:
return "A prompt"
- assert "prompt:my_prompt" in provider._components
+ assert "prompt:my_prompt@" in provider._components
def test_prompt_with_custom_name_registers(self):
"""Prompt with custom name should register under that name."""
@@ -488,8 +488,8 @@ class TestLocalProviderDecorators:
def my_prompt() -> str:
return "A prompt"
- assert "prompt:custom_prompt" in provider._components
- assert "prompt:my_prompt" not in provider._components
+ assert "prompt:custom_prompt@" in provider._components
+ assert "prompt:my_prompt@" not in provider._components
def test_prompt_enabled_false(self):
"""Prompt with enabled=False should be disabled."""
@@ -499,8 +499,8 @@ class TestLocalProviderDecorators:
def disabled_prompt() -> str:
return "should be disabled"
- assert "prompt:disabled_prompt" in provider._components
- prompt = provider._components["prompt:disabled_prompt"]
+ assert "prompt:disabled_prompt@" in provider._components
+ prompt = provider._components["prompt:disabled_prompt@"]
assert not provider._is_component_enabled(prompt)
async def test_prompt_enabled_false_not_listed(self):
@@ -577,8 +577,8 @@ class TestProviderToolTransformations:
)
# Get tool through layer with call_next
- async def get_tool(name: str):
- return await provider.get_tool(name)
+ async def get_tool(name: str, version=None):
+ return await provider.get_tool(name, version)
tool = await layer.get_tool("transformed_tool", get_tool)
assert tool is not None
@@ -603,8 +603,8 @@ class TestProviderToolTransformations:
{"my_tool": ToolTransformConfig(description="New description")}
)
- async def get_tool(name: str):
- return await provider.get_tool(name)
+ async def get_tool(name: str, version=None):
+ return await provider.get_tool(name, version)
tool = await layer.get_tool("my_tool", get_tool)
assert tool is not None
diff --git a/tests/server/providers/test_local_provider_prompts.py b/tests/server/providers/test_local_provider_prompts.py
index 93ca93c1b..34937c594 100644
--- a/tests/server/providers/test_local_provider_prompts.py
+++ b/tests/server/providers/test_local_provider_prompts.py
@@ -331,12 +331,12 @@ class TestPromptEnabled:
prompts = await mcp.get_prompts()
assert any(p.name == "sample_prompt" for p in prompts)
- mcp.disable(keys=["prompt:sample_prompt"])
+ mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert not any(p.name == "sample_prompt" for p in prompts)
- mcp.enable(keys=["prompt:sample_prompt"])
+ mcp.enable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert any(p.name == "sample_prompt" for p in prompts)
@@ -348,7 +348,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
- mcp.disable(keys=["prompt:sample_prompt"])
+ mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 0
@@ -362,11 +362,11 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
- mcp.disable(keys=["prompt:sample_prompt"])
+ mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert not any(p.name == "sample_prompt" for p in prompts)
- mcp.enable(keys=["prompt:sample_prompt"])
+ mcp.enable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 1
@@ -377,7 +377,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
- mcp.disable(keys=["prompt:sample_prompt"])
+ mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 0
@@ -394,7 +394,7 @@ class TestPromptEnabled:
prompt = await mcp.get_prompt("sample_prompt")
assert prompt is not None
- mcp.disable(keys=["prompt:sample_prompt"])
+ mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 0
@@ -408,7 +408,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
- mcp.disable(keys=["prompt:sample_prompt"])
+ mcp.disable(keys=["prompt:sample_prompt@"])
with pytest.raises(NotFoundError, match="Unknown prompt"):
await mcp.get_prompt("sample_prompt")
diff --git a/tests/server/providers/test_local_provider_resources.py b/tests/server/providers/test_local_provider_resources.py
index 95970b831..ab4d77ec2 100644
--- a/tests/server/providers/test_local_provider_resources.py
+++ b/tests/server/providers/test_local_provider_resources.py
@@ -738,12 +738,12 @@ class TestResourceEnabled:
resources = await mcp.get_resources()
assert any(str(r.uri) == "resource://data" for r in resources)
- mcp.disable(keys=["resource:resource://data"])
+ mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert not any(str(r.uri) == "resource://data" for r in resources)
- mcp.enable(keys=["resource:resource://data"])
+ mcp.enable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert any(str(r.uri) == "resource://data" for r in resources)
@@ -755,7 +755,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
- mcp.disable(keys=["resource:resource://data"])
+ mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 0
@@ -769,11 +769,11 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
- mcp.disable(keys=["resource:resource://data"])
+ mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert not any(str(r.uri) == "resource://data" for r in resources)
- mcp.enable(keys=["resource:resource://data"])
+ mcp.enable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 1
@@ -784,7 +784,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
- mcp.disable(keys=["resource:resource://data"])
+ mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 0
@@ -801,7 +801,7 @@ class TestResourceEnabled:
resource = await mcp.get_resource("resource://data")
assert resource is not None
- mcp.disable(keys=["resource:resource://data"])
+ mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 0
@@ -815,7 +815,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
- mcp.disable(keys=["resource:resource://data"])
+ mcp.disable(keys=["resource:resource://data@"])
with pytest.raises(NotFoundError, match="Unknown resource"):
await mcp.read_resource("resource://data")
@@ -891,12 +891,12 @@ class TestResourceTemplateEnabled:
templates = await mcp.get_resource_templates()
assert any(t.uri_template == "resource://{param}" for t in templates)
- mcp.disable(keys=["template:resource://{param}"])
+ mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert not any(t.uri_template == "resource://{param}" for t in templates)
- mcp.enable(keys=["template:resource://{param}"])
+ mcp.enable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert any(t.uri_template == "resource://{param}" for t in templates)
@@ -908,7 +908,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
- mcp.disable(keys=["template:resource://{param}"])
+ mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 0
@@ -922,11 +922,11 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
- mcp.disable(keys=["template:resource://{param}"])
+ mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert not any(t.uri_template == "resource://{param}" for t in templates)
- mcp.enable(keys=["template:resource://{param}"])
+ mcp.enable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 1
@@ -937,7 +937,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
- mcp.disable(keys=["template:resource://{param}"])
+ mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 0
@@ -954,7 +954,7 @@ class TestResourceTemplateEnabled:
template = await mcp.get_resource_template("resource://{param}")
assert template is not None
- mcp.disable(keys=["template:resource://{param}"])
+ mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 0
@@ -968,7 +968,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
- mcp.disable(keys=["template:resource://{param}"])
+ mcp.disable(keys=["template:resource://{param}@"])
with pytest.raises(NotFoundError, match="Unknown resource"):
await mcp.read_resource("resource://test")
diff --git a/tests/server/providers/test_local_provider_tools.py b/tests/server/providers/test_local_provider_tools.py
index e694ed7ea..cb4ea822c 100644
--- a/tests/server/providers/test_local_provider_tools.py
+++ b/tests/server/providers/test_local_provider_tools.py
@@ -1473,14 +1473,14 @@ class TestToolEnabled:
assert any(t.name == "sample_tool" for t in tools)
# Disable via server
- mcp.disable(keys=["tool:sample_tool"])
+ mcp.disable(keys=["tool:sample_tool@"])
# Tool should not be in list when disabled
tools = await mcp.get_tools()
assert not any(t.name == "sample_tool" for t in tools)
# Re-enable via server
- mcp.enable(keys=["tool:sample_tool"])
+ mcp.enable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert any(t.name == "sample_tool" for t in tools)
@@ -1491,7 +1491,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
- mcp.disable(keys=["tool:sample_tool"])
+ mcp.disable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 0
@@ -1505,8 +1505,8 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
- mcp.disable(keys=["tool:sample_tool"])
- mcp.enable(keys=["tool:sample_tool"])
+ mcp.disable(keys=["tool:sample_tool@"])
+ mcp.enable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 1
@@ -1517,7 +1517,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
- mcp.disable(keys=["tool:sample_tool"])
+ mcp.disable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 0
@@ -1534,7 +1534,7 @@ class TestToolEnabled:
tool = await mcp.get_tool(name="sample_tool")
assert tool is not None
- mcp.disable(keys=["tool:sample_tool"])
+ mcp.disable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 0
@@ -1548,7 +1548,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
- mcp.disable(keys=["tool:sample_tool"])
+ mcp.disable(keys=["tool:sample_tool@"])
with pytest.raises(NotFoundError, match="Unknown tool"):
await mcp.call_tool("sample_tool", {"x": 5})
diff --git a/tests/server/providers/test_transforming_provider.py b/tests/server/providers/test_transforming_provider.py
index fa1fe84e9..8a7d6a254 100644
--- a/tests/server/providers/test_transforming_provider.py
+++ b/tests/server/providers/test_transforming_provider.py
@@ -158,8 +158,8 @@ class TestTransformReverseLookup:
layer = Namespace("ns")
# Create call_next that delegates to provider
- async def get_tool(name: str):
- return await provider.get_tool(name)
+ async def get_tool(name: str, version=None):
+ return await provider.get_tool(name, version)
tool = await layer.get_tool("ns_my_tool", get_tool)
@@ -177,8 +177,8 @@ class TestTransformReverseLookup:
provider = FastMCPProvider(server)
layer = ToolTransform({"original": ToolTransformConfig(name="renamed")})
- async def get_tool(name: str):
- return await provider.get_tool(name)
+ async def get_tool(name: str, version=None):
+ return await provider.get_tool(name, version)
tool = await layer.get_tool("renamed", get_tool)
@@ -196,8 +196,8 @@ class TestTransformReverseLookup:
provider = FastMCPProvider(server)
layer = Namespace("ns")
- async def get_resource(uri: str):
- return await provider.get_resource(uri)
+ async def get_resource(uri: str, version=None):
+ return await provider.get_resource(uri, version)
resource = await layer.get_resource("resource://ns/data", get_resource)
@@ -215,8 +215,8 @@ class TestTransformReverseLookup:
provider = FastMCPProvider(server)
layer = Namespace("ns")
- async def get_tool(name: str):
- return await provider.get_tool(name)
+ async def get_tool(name: str, version=None):
+ return await provider.get_tool(name, version)
# Wrong namespace prefix
assert await layer.get_tool("wrong_my_tool", get_tool) is None
diff --git a/tests/server/tasks/test_custom_subclass_tasks.py b/tests/server/tasks/test_custom_subclass_tasks.py
index a361560fb..381fd24ac 100644
--- a/tests/server/tasks/test_custom_subclass_tasks.py
+++ b/tests/server/tasks/test_custom_subclass_tasks.py
@@ -125,7 +125,7 @@ async def test_custom_tool_registers_with_docket():
# Should register self.run with docket using prefixed key
mock_docket.register.assert_called_once()
call_args = mock_docket.register.call_args
- assert call_args[1]["names"] == ["tool:test"]
+ assert call_args[1]["names"] == ["tool:test@"]
async def test_custom_tool_forbidden_does_not_register():
diff --git a/tests/server/tasks/test_server_tasks_parameter.py b/tests/server/tasks/test_server_tasks_parameter.py
index b175a46ad..8bb0ec6c8 100644
--- a/tests/server/tasks/test_server_tasks_parameter.py
+++ b/tests/server/tasks/test_server_tasks_parameter.py
@@ -31,9 +31,9 @@ async def test_server_tasks_true_defaults_all_components():
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
assert docket is not None
- assert "tool:my_tool" in docket.tasks
- assert "prompt:my_prompt" in docket.tasks
- assert "resource:test://resource" in docket.tasks
+ assert "tool:my_tool@" in docket.tasks
+ assert "prompt:my_prompt@" in docket.tasks
+ assert "resource:test://resource@" in docket.tasks
# Tool should support background execution
tool_task = await client.call_tool("my_tool", task=True)
@@ -118,9 +118,9 @@ async def test_component_explicit_false_overrides_server_true():
docket = mcp.docket
assert docket is not None
assert (
- "tool:no_task_tool" not in docket.tasks
+ "tool:no_task_tool@" not in docket.tasks
) # task=False means not registered
- assert "tool:default_tool" in docket.tasks # Inherits tasks=True
+ assert "tool:default_tool@" in docket.tasks # Inherits tasks=True
# Explicit False (mode="forbidden") returns error when called with task=True
no_task = await client.call_tool("no_task_tool", task=True)
@@ -150,8 +150,8 @@ async def test_component_explicit_true_overrides_server_false():
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
- assert "tool:task_tool" in docket.tasks # task=True means registered
- assert "tool:default_tool" not in docket.tasks # Inherits tasks=False
+ assert "tool:task_tool@" in docket.tasks # task=True means registered
+ assert "tool:default_tool@" not in docket.tasks # Inherits tasks=False
# Explicit True should support background execution despite server default
task = await client.call_tool("task_tool", task=True)
@@ -205,14 +205,14 @@ async def test_mixed_explicit_and_inherited():
docket = mcp.docket
assert docket is not None
# task=True (explicit or inherited) means registered (with prefixed keys)
- assert "tool:inherited_tool" in docket.tasks
- assert "tool:explicit_true_tool" in docket.tasks
- assert "prompt:inherited_prompt" in docket.tasks
- assert "resource:test://inherited" in docket.tasks
+ assert "tool:inherited_tool@" in docket.tasks
+ assert "tool:explicit_true_tool@" in docket.tasks
+ assert "prompt:inherited_prompt@" in docket.tasks
+ assert "resource:test://inherited@" in docket.tasks
# task=False means NOT registered
- assert "tool:explicit_false_tool" not in docket.tasks
- assert "prompt:explicit_false_prompt" not in docket.tasks
- assert "resource:test://explicit_false" not in docket.tasks
+ assert "tool:explicit_false_tool@" not in docket.tasks
+ assert "prompt:explicit_false_prompt@" not in docket.tasks
+ assert "resource:test://explicit_false@" not in docket.tasks
# Tools
inherited = await client.call_tool("inherited_tool", task=True)
@@ -331,7 +331,7 @@ async def test_task_with_custom_tool_name():
# Verify the tool is registered with its custom name in Docket (prefixed key)
docket = mcp.docket
assert docket is not None
- assert "tool:custom-tool-name" in docket.tasks
+ assert "tool:custom-tool-name@" in docket.tasks
# Call the tool as a task using its custom name
task = await client.call_tool("custom-tool-name", task=True)
@@ -355,7 +355,7 @@ async def test_task_with_custom_resource_name():
# Verify the resource is registered with its key (prefixed URI) in Docket
docket = mcp.docket
assert docket is not None
- assert "resource:test://resource" in docket.tasks
+ assert "resource:test://resource@" in docket.tasks
# Call the resource as a task
task = await client.read_resource("test://resource", task=True)
@@ -379,7 +379,7 @@ async def test_task_with_custom_template_name():
# Verify the template is registered with its key (prefixed uri_template) in Docket
docket = mcp.docket
assert docket is not None
- assert "template:test://{item_id}" in docket.tasks
+ assert "template:test://{item_id}@" in docket.tasks
# Call the template as a task
task = await client.read_resource("test://123", task=True)
diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py
index ae5e57c07..1d110effe 100644
--- a/tests/server/telemetry/test_server_tracing.py
+++ b/tests/server/telemetry/test_server_tracing.py
@@ -40,7 +40,7 @@ class TestToolTracing:
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "tool"
- assert span.attributes["fastmcp.component.key"] == "tool:greet"
+ assert span.attributes["fastmcp.component.key"] == "tool:greet@"
async def test_call_tool_with_error_sets_status(
self, trace_exporter: InMemorySpanExporter
@@ -108,7 +108,7 @@ class TestResourceTracing:
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "resource"
- assert span.attributes["fastmcp.component.key"] == "resource:config://app"
+ assert span.attributes["fastmcp.component.key"] == "resource:config://app@"
async def test_read_resource_template_creates_span(
self, trace_exporter: InMemorySpanExporter
@@ -138,7 +138,7 @@ class TestResourceTracing:
assert span.attributes["fastmcp.component.type"] == "resource_template"
assert (
span.attributes["fastmcp.component.key"]
- == "template:users://{user_id}/profile"
+ == "template:users://{user_id}/profile@"
)
async def test_read_nonexistent_resource_sets_error(
@@ -186,7 +186,7 @@ class TestPromptTracing:
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "prompt"
- assert span.attributes["fastmcp.component.key"] == "prompt:greeting"
+ assert span.attributes["fastmcp.component.key"] == "prompt:greeting@"
async def test_render_nonexistent_prompt_sets_error(
self, trace_exporter: InMemorySpanExporter
diff --git a/tests/server/test_providers.py b/tests/server/test_providers.py
index 1bcefe8ab..ec71902b1 100644
--- a/tests/server/test_providers.py
+++ b/tests/server/test_providers.py
@@ -14,6 +14,7 @@ from fastmcp.resources.resource import Resource
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.providers import Provider
from fastmcp.tools.tool import Tool, ToolResult
+from fastmcp.utilities.versions import VersionSpec
class SimpleTool(Tool):
@@ -51,9 +52,17 @@ class SimpleToolProvider(Provider):
self.list_tools_call_count += 1
return self._tools
- async def get_tool(self, name: str) -> Tool | None:
+ async def get_tool(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Tool | None:
self.get_tool_call_count += 1
- return next((t for t in self._tools if t.name == name), None)
+ matching = [t for t in self._tools if t.name == name]
+ if not matching:
+ return None
+ if version is None:
+ return matching[0] # Return first (for testing simplicity)
+ matching = [t for t in matching if version.matches(t.version)]
+ return matching[0] if matching else None
class ListOnlyProvider(Provider):
diff --git a/tests/server/test_versioning.py b/tests/server/test_versioning.py
new file mode 100644
index 000000000..7742ae485
--- /dev/null
+++ b/tests/server/test_versioning.py
@@ -0,0 +1,990 @@
+"""Tests for component versioning functionality."""
+# ruff: noqa: F811 # Intentional function redefinition for version testing
+
+from __future__ import annotations
+
+from mcp.types import TextContent
+
+from fastmcp import FastMCP
+from fastmcp.utilities.versions import (
+ VersionKey,
+ compare_versions,
+ is_version_greater,
+)
+
+
+class TestVersionKey:
+ """Tests for VersionKey comparison class."""
+
+ def test_none_sorts_lowest(self):
+ """None (unversioned) should sort lower than any version."""
+ assert VersionKey(None) < VersionKey("1.0")
+ assert VersionKey(None) < VersionKey("0.1")
+ assert VersionKey(None) < VersionKey("anything")
+
+ def test_none_equals_none(self):
+ """Two None versions should be equal."""
+ assert VersionKey(None) == VersionKey(None)
+ assert not (VersionKey(None) < VersionKey(None))
+ assert not (VersionKey(None) > VersionKey(None))
+
+ def test_pep440_versions_compared_semantically(self):
+ """Valid PEP 440 versions should compare semantically."""
+ assert VersionKey("1.0") < VersionKey("2.0")
+ assert VersionKey("1.0") < VersionKey("1.1")
+ assert VersionKey("1.9") < VersionKey("1.10") # Semantic, not string
+ assert VersionKey("2") < VersionKey("10") # Semantic, not string
+
+ def test_v_prefix_stripped(self):
+ """Versions with 'v' prefix should be handled correctly."""
+ assert VersionKey("v1.0") == VersionKey("1.0")
+ assert VersionKey("v2.0") > VersionKey("v1.0")
+
+ def test_string_fallback_for_invalid_versions(self):
+ """Invalid PEP 440 versions should fall back to string comparison."""
+ # Dates are not valid PEP 440
+ assert VersionKey("2024-01-01") < VersionKey("2025-01-01")
+ # String comparison (lexicographic)
+ assert VersionKey("alpha") < VersionKey("beta")
+
+ def test_pep440_sorts_before_strings(self):
+ """PEP 440 versions sort before invalid string versions."""
+ # "1.0" is valid PEP 440, "not-semver" is not
+ assert VersionKey("1.0") < VersionKey("not-semver")
+ assert VersionKey("999.0") < VersionKey("aaa") # PEP 440 < string
+
+ def test_repr(self):
+ """Test string representation."""
+ assert repr(VersionKey("1.0")) == "VersionKey('1.0')"
+ assert repr(VersionKey(None)) == "VersionKey(None)"
+
+
+class TestVersionFunctions:
+ """Tests for version comparison functions."""
+
+ def test_compare_versions(self):
+ """Test compare_versions function."""
+ assert compare_versions("1.0", "2.0") == -1
+ assert compare_versions("2.0", "1.0") == 1
+ assert compare_versions("1.0", "1.0") == 0
+ assert compare_versions(None, "1.0") == -1
+ assert compare_versions("1.0", None) == 1
+ assert compare_versions(None, None) == 0
+
+ def test_is_version_greater(self):
+ """Test is_version_greater function."""
+ assert is_version_greater("2.0", "1.0")
+ assert not is_version_greater("1.0", "2.0")
+ assert not is_version_greater("1.0", "1.0")
+ assert is_version_greater("1.0", None)
+ assert not is_version_greater(None, "1.0")
+
+
+class TestComponentVersioning:
+ """Tests for versioning in FastMCP components."""
+
+ async def test_tool_with_version(self):
+ """Tool version should be reflected in key."""
+ mcp = FastMCP()
+
+ @mcp.tool(version="2.0")
+ def my_tool(x: int) -> int:
+ return x * 2
+
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].name == "my_tool"
+ assert tools[0].version == "2.0"
+ assert tools[0].key == "tool:my_tool@2.0"
+
+ async def test_tool_without_version(self):
+ """Tool without version should have @ sentinel in key but empty version."""
+ mcp = FastMCP()
+
+ @mcp.tool
+ def my_tool(x: int) -> int:
+ return x * 2
+
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version is None
+ # Keys always have @ sentinel for unambiguous parsing
+ assert tools[0].key == "tool:my_tool@"
+
+ async def test_tool_version_as_int(self):
+ """Tool version as int should be coerced to string."""
+ mcp = FastMCP()
+
+ @mcp.tool(version=2)
+ def my_tool(x: int) -> int:
+ return x * 2
+
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "2"
+ assert tools[0].key == "tool:my_tool@2"
+
+ async def test_tool_version_zero_is_truthy(self):
+ """Version 0 should become "0" (truthy string), not empty."""
+ mcp = FastMCP()
+
+ @mcp.tool(version=0)
+ def my_tool(x: int) -> int:
+ return x * 2
+
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "0"
+ assert tools[0].key == "tool:my_tool@0" # Not "tool:my_tool@"
+
+ async def test_multiple_tool_versions_deduplicated(self):
+ """Multiple versions of same tool should deduplicate to highest."""
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.0")
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ @mcp.tool(version="2.0")
+ def add(x: int, y: int, z: int = 0) -> int:
+ return x + y + z
+
+ tools = await mcp.get_tools()
+ # Should only show the highest version
+ assert len(tools) == 1
+ assert tools[0].name == "add"
+ assert tools[0].version == "2.0"
+
+ async def test_call_tool_invokes_highest_version(self):
+ """Calling a tool by name should invoke the highest version."""
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.0")
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ @mcp.tool(version="2.0")
+ def add(x: int, y: int) -> int:
+ return (x + y) * 10 # Different behavior to distinguish
+
+ result = await mcp.call_tool("add", {"x": 1, "y": 2})
+ # Should invoke v2.0 which multiplies by 10
+ assert isinstance(result.content[0], TextContent)
+ assert result.content[0].text == "30"
+
+ async def test_mixing_versioned_and_unversioned_rejected(self):
+ """Cannot mix versioned and unversioned tools with the same name."""
+ import pytest
+
+ mcp = FastMCP()
+
+ @mcp.tool
+ def my_tool() -> str:
+ return "unversioned"
+
+ # Adding versioned tool when unversioned exists should fail
+ with pytest.raises(ValueError, match="versioned.*unversioned"):
+
+ @mcp.tool(version="1.0")
+ def my_tool() -> str:
+ return "v1.0"
+
+ async def test_mixing_unversioned_after_versioned_rejected(self):
+ """Cannot add unversioned tool when versioned exists."""
+ import pytest
+
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.0")
+ def my_tool() -> str:
+ return "v1.0"
+
+ # Adding unversioned tool when versioned exists should fail
+ with pytest.raises(ValueError, match="unversioned.*versioned"):
+
+ @mcp.tool
+ def my_tool() -> str:
+ return "unversioned"
+
+ async def test_resource_with_version(self):
+ """Resource version should work like tool version."""
+ mcp = FastMCP()
+
+ @mcp.resource("file://config", version="1.0")
+ def config_v1() -> str:
+ return "config v1"
+
+ @mcp.resource("file://config", version="2.0")
+ def config_v2() -> str:
+ return "config v2"
+
+ resources = await mcp.get_resources()
+ assert len(resources) == 1
+ assert resources[0].version == "2.0"
+
+ async def test_prompt_with_version(self):
+ """Prompt version should work like tool version."""
+ mcp = FastMCP()
+
+ @mcp.prompt(version="1.0")
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ @mcp.prompt(version="2.0")
+ def greet(name: str) -> str:
+ return f"Greetings, {name}!"
+
+ prompts = await mcp.get_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].version == "2.0"
+
+
+class TestVersionSorting:
+ """Tests for version sorting behavior."""
+
+ async def test_semantic_version_sorting(self):
+ """Versions should sort semantically, not lexicographically."""
+ mcp = FastMCP()
+
+ # Add versions out of order
+ @mcp.tool(version="1")
+ def count() -> int:
+ return 1
+
+ @mcp.tool(version="10")
+ def count() -> int:
+ return 10
+
+ @mcp.tool(version="2")
+ def count() -> int:
+ return 2
+
+ tools = await mcp.get_tools()
+ # Should keep v10 as highest (semantic: 10 > 2 > 1)
+ assert len(tools) == 1
+ assert tools[0].version == "10"
+
+ result = await mcp.call_tool("count", {})
+ assert isinstance(result.content[0], TextContent)
+ assert result.content[0].text == "10"
+
+ async def test_semver_sorting(self):
+ """Full semver versions should sort correctly."""
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.2.3")
+ def info() -> str:
+ return "1.2.3"
+
+ @mcp.tool(version="1.2.10")
+ def info() -> str:
+ return "1.2.10"
+
+ @mcp.tool(version="1.10.1")
+ def info() -> str:
+ return "1.10.1"
+
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ # 1.10.1 > 1.2.10 > 1.2.3 (semantic)
+ assert tools[0].version == "1.10.1"
+
+ async def test_v_prefix_normalized(self):
+ """Versions with 'v' prefix should compare correctly."""
+ mcp = FastMCP()
+
+ @mcp.tool(version="v1.0")
+ def calc() -> int:
+ return 1
+
+ @mcp.tool(version="v2.0")
+ def calc() -> int:
+ return 2
+
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "v2.0"
+
+
+class TestMountedServerVersioning:
+ """Tests for versioning in mounted servers (FastMCPProvider)."""
+
+ async def test_mounted_tool_preserves_version(self):
+ """Mounted tools should preserve their version info."""
+ child = FastMCP("Child")
+
+ @child.tool(version="2.0")
+ def add(x: int, y: int) -> int:
+ return x + y
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+
+ tools = await parent.get_tools()
+ assert len(tools) == 1
+ assert tools[0].name == "child_add"
+ assert tools[0].version == "2.0"
+
+ async def test_mounted_resource_preserves_version(self):
+ """Mounted resources should preserve their version info."""
+ child = FastMCP("Child")
+
+ @child.resource("file://config", version="1.5")
+ def config() -> str:
+ return "config data"
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+
+ resources = await parent.get_resources()
+ assert len(resources) == 1
+ assert resources[0].version == "1.5"
+
+ async def test_mounted_prompt_preserves_version(self):
+ """Mounted prompts should preserve their version info."""
+ child = FastMCP("Child")
+
+ @child.prompt(version="3.0")
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+
+ prompts = await parent.get_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].name == "child_greet"
+ assert prompts[0].version == "3.0"
+
+ async def test_mounted_get_tool_with_version(self):
+ """Should be able to get specific version from mounted server."""
+ child = FastMCP("Child")
+
+ @child.tool(version="1.0")
+ def calc() -> int:
+ return 1
+
+ @child.tool(version="2.0")
+ def calc() -> int:
+ return 2
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+
+ # Get highest version (default)
+ tool = await parent.get_tool("child_calc")
+ assert tool is not None
+ assert tool.version == "2.0"
+
+ # Get specific version
+ tool_v1 = await parent.get_tool("child_calc", version="1.0")
+ assert tool_v1 is not None
+ assert tool_v1.version == "1.0"
+
+ async def test_mounted_multiple_versions_deduplicates(self):
+ """Mounted server with multiple versions should show only highest."""
+ child = FastMCP("Child")
+
+ @child.tool(version="1.0")
+ def my_tool() -> str:
+ return "v1"
+
+ @child.tool(version="3.0")
+ def my_tool() -> str:
+ return "v3"
+
+ @child.tool(version="2.0")
+ def my_tool() -> str:
+ return "v2"
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+
+ tools = await parent.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "3.0"
+
+ async def test_mounted_call_tool_uses_highest_version(self):
+ """Calling mounted tool should use highest version."""
+ child = FastMCP("Child")
+
+ @child.tool(version="1.0")
+ def double(x: int) -> int:
+ return x * 2
+
+ @child.tool(version="2.0")
+ def double(x: int) -> int:
+ return x * 2 + 100 # Different behavior
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+
+ result = await parent.call_tool("child_double", {"x": 5})
+ # Should use v2.0 which adds 100
+ assert isinstance(result.content[0], TextContent)
+ assert result.content[0].text == "110"
+
+
+class TestVersionFilter:
+ """Tests for VersionFilter transform."""
+
+ async def test_version_lt_filters_high_versions(self):
+ """VersionFilter(version_lt='3.0') hides v3+, shows v1 and v2."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.0")
+ def calc() -> int:
+ return 1
+
+ @mcp.tool(version="2.0")
+ def calc() -> int:
+ return 2
+
+ @mcp.tool(version="3.0")
+ def calc() -> int:
+ return 3
+
+ # Without filter, should show v3 (highest)
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "3.0"
+
+ # With filter, should show v2 (highest below 3.0)
+ mcp.add_transform(VersionFilter(version_lt="3.0"))
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "2.0"
+
+ async def test_version_gte_filters_low_versions(self):
+ """VersionFilter(version_gte='2.0') hides v1, shows v2 and v3."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.0")
+ def add(x: int) -> int:
+ return x + 1
+
+ @mcp.tool(version="2.0")
+ def add(x: int) -> int:
+ return x + 2
+
+ @mcp.tool(version="3.0")
+ def add(x: int) -> int:
+ return x + 3
+
+ mcp.add_transform(VersionFilter(version_gte="2.0"))
+
+ # Should show v3 (highest >= 2.0)
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "3.0"
+
+ # Can request specific versions in range
+ tool_v2 = await mcp.get_tool("add", version="2.0")
+ assert tool_v2 is not None
+ assert tool_v2.version == "2.0"
+
+ # Cannot request version outside range
+ import pytest
+
+ from fastmcp.exceptions import NotFoundError
+
+ with pytest.raises(NotFoundError):
+ await mcp.get_tool("add", version="1.0")
+
+ async def test_version_range(self):
+ """VersionFilter(version_gte='2.0', version_lt='3.0') shows only v2.x."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.0")
+ def calc() -> int:
+ return 1
+
+ @mcp.tool(version="2.0")
+ def calc() -> int:
+ return 2
+
+ @mcp.tool(version="2.5")
+ def calc() -> int:
+ return 25
+
+ @mcp.tool(version="3.0")
+ def calc() -> int:
+ return 3
+
+ mcp.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0"))
+
+ # Should show v2.5 (highest in range)
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "2.5"
+
+ # Can request specific versions in range
+ tool_v2 = await mcp.get_tool("calc", version="2.0")
+ assert tool_v2 is not None
+ assert tool_v2.version == "2.0"
+
+ # Versions outside range are not accessible
+ import pytest
+
+ from fastmcp.exceptions import NotFoundError
+
+ with pytest.raises(NotFoundError):
+ await mcp.get_tool("calc", version="1.0")
+
+ with pytest.raises(NotFoundError):
+ await mcp.get_tool("calc", version="3.0")
+
+ async def test_unversioned_always_passes(self):
+ """Unversioned components pass through any filter."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.tool
+ def unversioned_tool() -> str:
+ return "unversioned"
+
+ @mcp.tool(version="5.0")
+ def versioned_tool() -> str:
+ return "v5"
+
+ # Filter that would exclude v5.0
+ mcp.add_transform(VersionFilter(version_lt="3.0"))
+
+ tools = await mcp.get_tools()
+ names = [t.name for t in tools]
+ assert "unversioned_tool" in names
+ assert "versioned_tool" not in names
+
+ async def test_date_versions(self):
+ """Works with date-based versions like '2025-01-15'."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.tool(version="2025-01-01")
+ def report() -> str:
+ return "jan"
+
+ @mcp.tool(version="2025-06-01")
+ def report() -> str:
+ return "jun"
+
+ @mcp.tool(version="2025-12-01")
+ def report() -> str:
+ return "dec"
+
+ # Q1 API: before April
+ mcp.add_transform(VersionFilter(version_lt="2025-04-01"))
+
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "2025-01-01"
+
+ async def test_get_tool_respects_filter(self):
+ """get_tool() raises NotFoundError if highest version is filtered out."""
+ import pytest
+
+ from fastmcp.exceptions import NotFoundError
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.tool(version="5.0")
+ def only_v5() -> str:
+ return "v5"
+
+ mcp.add_transform(VersionFilter(version_lt="3.0"))
+
+ # Tool exists but is filtered out
+ with pytest.raises(NotFoundError):
+ await mcp.get_tool("only_v5")
+
+ async def test_must_specify_at_least_one(self):
+ """VersionFilter() with no args raises ValueError."""
+ import pytest
+
+ from fastmcp.server.transforms import VersionFilter
+
+ with pytest.raises(ValueError, match="At least one of"):
+ VersionFilter()
+
+ async def test_resources_filtered(self):
+ """Resources are filtered by version."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.resource("file://config", version="1.0")
+ def config_v1() -> str:
+ return "v1"
+
+ @mcp.resource("file://config", version="2.0")
+ def config_v2() -> str:
+ return "v2"
+
+ mcp.add_transform(VersionFilter(version_lt="2.0"))
+
+ resources = await mcp.get_resources()
+ assert len(resources) == 1
+ assert resources[0].version == "1.0"
+
+ async def test_prompts_filtered(self):
+ """Prompts are filtered by version."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.prompt(version="1.0")
+ def greet(name: str) -> str:
+ return f"Hi {name}"
+
+ @mcp.prompt(version="2.0")
+ def greet(name: str) -> str:
+ return f"Hello {name}"
+
+ mcp.add_transform(VersionFilter(version_lt="2.0"))
+
+ prompts = await mcp.get_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].version == "1.0"
+
+ async def test_repr(self):
+ """Test VersionFilter string representation."""
+ from fastmcp.server.transforms import VersionFilter
+
+ f1 = VersionFilter(version_lt="3.0")
+ assert repr(f1) == "VersionFilter(version_lt='3.0')"
+
+ f2 = VersionFilter(version_gte="2.0", version_lt="3.0")
+ assert repr(f2) == "VersionFilter(version_gte='2.0', version_lt='3.0')"
+
+ f3 = VersionFilter(version_gte="1.0")
+ assert repr(f3) == "VersionFilter(version_gte='1.0')"
+
+
+class TestVersionMixingValidation:
+ """Tests for versioned/unversioned mixing prevention."""
+
+ async def test_resource_mixing_rejected(self):
+ """Cannot mix versioned and unversioned resources with the same URI."""
+ import pytest
+
+ mcp = FastMCP()
+
+ @mcp.resource("file://config", version="1.0")
+ def config_v1() -> str:
+ return "v1"
+
+ with pytest.raises(ValueError, match="unversioned.*versioned"):
+
+ @mcp.resource("file://config")
+ def config_unversioned() -> str:
+ return "unversioned"
+
+ async def test_prompt_mixing_rejected(self):
+ """Cannot mix versioned and unversioned prompts with the same name."""
+ import pytest
+
+ mcp = FastMCP()
+
+ @mcp.prompt
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ with pytest.raises(ValueError, match="versioned.*unversioned"):
+
+ @mcp.prompt(version="1.0")
+ def greet(name: str) -> str:
+ return f"Hi, {name}!"
+
+ async def test_multiple_versions_allowed(self):
+ """Multiple versioned components with same name are allowed."""
+ mcp = FastMCP()
+
+ @mcp.tool(version="1.0")
+ def calc() -> int:
+ return 1
+
+ @mcp.tool(version="2.0")
+ def calc() -> int:
+ return 2
+
+ @mcp.tool(version="3.0")
+ def calc() -> int:
+ return 3
+
+ # All versioned - this should work
+ tools = await mcp.get_tools()
+ assert len(tools) == 1
+ assert tools[0].version == "3.0"
+
+
+class TestMountedVersionFiltering:
+ """Tests for version filtering with mounted servers (FastMCPProvider).
+
+ Note: For mounted servers, list_* methods show what the child exposes (already
+ deduplicated to highest version). get_* methods support range filtering via
+ VersionSpec propagation to FastMCPProvider.
+ """
+
+ async def test_mounted_get_tool_with_range_filter(self):
+ """FastMCPProvider.get_tool applies range filtering from VersionSpec."""
+ from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
+ from fastmcp.utilities.versions import VersionSpec
+
+ child = FastMCP("Child")
+
+ @child.tool(version="2.0")
+ def calc() -> int:
+ return 2
+
+ provider = FastMCPProvider(child)
+
+ # Without range spec, should return the tool
+ tool = await provider.get_tool("calc")
+ assert tool is not None
+ assert tool.version == "2.0"
+
+ # With range spec that excludes v2.0, should return None
+ tool = await provider.get_tool("calc", version=VersionSpec(lt="2.0"))
+ assert tool is None
+
+ # With range spec that includes v2.0, should return the tool
+ tool = await provider.get_tool("calc", version=VersionSpec(gte="2.0"))
+ assert tool is not None
+ assert tool.version == "2.0"
+
+ async def test_mounted_get_resource_with_range_filter(self):
+ """FastMCPProvider.get_resource applies range filtering from VersionSpec."""
+ from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
+ from fastmcp.utilities.versions import VersionSpec
+
+ child = FastMCP("Child")
+
+ @child.resource("file://data/", version="2.0")
+ def data() -> str:
+ return "data"
+
+ provider = FastMCPProvider(child)
+
+ # Without range spec, should return the resource
+ resource = await provider.get_resource("file://data/")
+ assert resource is not None
+ assert resource.version == "2.0"
+
+ # With range spec that excludes v2.0, should return None
+ resource = await provider.get_resource(
+ "file://data/", version=VersionSpec(lt="2.0")
+ )
+ assert resource is None
+
+ async def test_mounted_get_prompt_with_range_filter(self):
+ """FastMCPProvider.get_prompt applies range filtering from VersionSpec."""
+ from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
+ from fastmcp.utilities.versions import VersionSpec
+
+ child = FastMCP("Child")
+
+ @child.prompt(version="2.0")
+ def greet(name: str) -> str:
+ return f"Hello {name}"
+
+ provider = FastMCPProvider(child)
+
+ # Without range spec, should return the prompt
+ prompt = await provider.get_prompt("greet")
+ assert prompt is not None
+ assert prompt.version == "2.0"
+
+ # With range spec that excludes v2.0, should return None
+ prompt = await provider.get_prompt("greet", version=VersionSpec(lt="2.0"))
+ assert prompt is None
+
+ async def test_mounted_unversioned_passes_version_filter(self):
+ """Unversioned components in mounted servers pass through version filters."""
+ from fastmcp.server.transforms import VersionFilter
+
+ child = FastMCP("Child")
+
+ @child.tool
+ def unversioned_tool() -> str:
+ return "unversioned"
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+ parent.add_transform(VersionFilter(version_lt="3.0"))
+
+ # Unversioned should pass through
+ tools = await parent.get_tools()
+ assert len(tools) == 1
+ assert tools[0].name == "child_unversioned_tool"
+ assert tools[0].version is None
+
+ async def test_version_filter_filters_out_high_mounted_version(self):
+ """VersionFilter hides mounted components outside the range."""
+ from fastmcp.server.transforms import VersionFilter
+
+ child = FastMCP("Child")
+
+ @child.tool(version="5.0")
+ def high_version_tool() -> int:
+ return 5
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+ parent.add_transform(VersionFilter(version_lt="3.0"))
+
+ # v5.0 is outside the filter range, so it should be hidden
+ tools = await parent.get_tools()
+ assert len(tools) == 0
+
+ # get_tool should also return None (respects filter)
+ import pytest
+
+ from fastmcp.exceptions import NotFoundError
+
+ with pytest.raises(NotFoundError):
+ await parent.get_tool("child_high_version_tool")
+
+
+class TestMountedRangeFiltering:
+ """Tests for version range filtering with mounted servers."""
+
+ async def test_mounted_lower_version_selected_by_filter(self):
+ """When parent has filter <2.0 and child has v1.0+v3.0, should get v1.0."""
+ from fastmcp.server.transforms import VersionFilter
+
+ child = FastMCP("Child")
+
+ @child.tool(version="1.0")
+ def calc() -> int:
+ return 1
+
+ @child.tool(version="3.0")
+ def calc() -> int:
+ return 3
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+ parent.add_transform(VersionFilter(version_lt="2.0"))
+
+ # Should return v1.0 (the highest version that matches <2.0)
+ tool = await parent.get_tool("child_calc")
+ assert tool is not None
+ assert tool.version == "1.0"
+
+ async def test_explicit_version_honored_within_filter_range(self):
+ """Explicit version="1.0" request should work within filter range."""
+ from fastmcp.server.transforms import VersionFilter
+
+ child = FastMCP("Child")
+
+ @child.tool(version="1.0")
+ def calc() -> int:
+ return 1
+
+ @child.tool(version="2.0")
+ def calc() -> int:
+ return 2
+
+ @child.tool(version="3.0")
+ def calc() -> int:
+ return 3
+
+ parent = FastMCP("Parent")
+ parent.mount(child, "child")
+ parent.add_transform(VersionFilter(version_gte="1.0", version_lt="3.0"))
+
+ # Request specific version within range
+ tool = await parent.get_tool("child_calc", version="1.0")
+ assert tool is not None
+ assert tool.version == "1.0"
+
+ # Request version outside range should fail
+ import pytest
+
+ from fastmcp.exceptions import NotFoundError
+
+ with pytest.raises(NotFoundError):
+ await parent.get_tool("child_calc", version="3.0")
+
+
+class TestUnversionedExemption:
+ """Tests confirming unversioned components bypass version filters."""
+
+ async def test_unversioned_bypasses_version_filter(self):
+ """Unversioned components pass through any VersionFilter - by design."""
+ from fastmcp.server.transforms import VersionFilter
+
+ mcp = FastMCP()
+
+ @mcp.tool
+ def unversioned_tool() -> str:
+ return "unversioned"
+
+ @mcp.tool(version="5.0")
+ def versioned_tool() -> str:
+ return "v5"
+
+ # Filter that would exclude v5.0
+ mcp.add_transform(VersionFilter(version_lt="3.0"))
+
+ tools = await mcp.get_tools()
+ names = [t.name for t in tools]
+
+ # Unversioned passes through (exempt from filtering)
+ assert "unversioned_tool" in names
+ # Versioned is filtered out
+ assert "versioned_tool" not in names
+
+ async def test_unversioned_returned_for_exact_version_request(self):
+ """Requesting exact version of unversioned tool returns the tool."""
+ mcp = FastMCP()
+
+ @mcp.tool
+ def my_tool() -> str:
+ return "unversioned"
+
+ # Even with explicit version request, unversioned tool is returned
+ # (it's the only version that exists, and unversioned matches any spec)
+ tool = await mcp.get_tool("my_tool", version="1.0")
+ assert tool is not None
+ assert tool.version is None
+
+ async def test_unversioned_matches_any_version_spec(self):
+ """VersionSpec.matches(None) returns True for any spec."""
+ from fastmcp.utilities.versions import VersionSpec
+
+ # Unversioned matches exact version specs
+ assert VersionSpec(eq="1.0").matches(None) is True
+
+ # Unversioned matches range specs
+ assert VersionSpec(gte="1.0", lt="3.0").matches(None) is True
+
+ # Unversioned matches open specs
+ assert VersionSpec(lt="5.0").matches(None) is True
+ assert VersionSpec(gte="1.0").matches(None) is True
+
+
+class TestVersionValidation:
+ """Tests for version string validation."""
+
+ async def test_version_with_at_symbol_rejected(self):
+ """Version strings containing '@' should be rejected."""
+ import pytest
+ from pydantic import ValidationError
+
+ mcp = FastMCP()
+
+ with pytest.raises(ValidationError, match="cannot contain '@'"):
+
+ @mcp.tool(version="1.0@beta")
+ def my_tool() -> str:
+ return "test"
diff --git a/tests/tools/test_tool_transform.py b/tests/tools/test_tool_transform.py
index 4492c57da..af81e3beb 100644
--- a/tests/tools/test_tool_transform.py
+++ b/tests/tools/test_tool_transform.py
@@ -1054,13 +1054,13 @@ class TestEnableDisable:
return x + y
# Get the registered Tool object from the server
- add_tool = await mcp._local_provider.get_component("tool:add")
+ add_tool = await mcp._local_provider.get_tool("add")
assert isinstance(add_tool, Tool)
new_add = Tool.from_tool(add_tool, name="new_add")
mcp.add_tool(new_add)
# Disable original tool, but new_add should still work
- mcp.disable(keys=["tool:add"])
+ mcp.disable(keys=["tool:add@"])
async with Client(mcp) as client:
tools = await client.list_tools()
@@ -1081,13 +1081,13 @@ class TestEnableDisable:
return x + y
# Get the registered Tool object from the server
- add_tool = await mcp._local_provider.get_component("tool:add")
+ add_tool = await mcp._local_provider.get_tool("add")
assert isinstance(add_tool, Tool)
new_add = Tool.from_tool(add_tool, name="new_add")
mcp.add_tool(new_add)
# Disable both tools via server
- mcp.disable(keys=["tool:add", "tool:new_add"])
+ mcp.disable(keys=["tool:add@", "tool:new_add@"])
async with Client(mcp) as client:
tools = await client.list_tools()
diff --git a/tests/utilities/test_components.py b/tests/utilities/test_components.py
index 9979b0988..fa01a7dd6 100644
--- a/tests/utilities/test_components.py
+++ b/tests/utilities/test_components.py
@@ -82,8 +82,9 @@ class TestFastMCPComponent:
assert component.meta == meta
def test_key_property_without_custom_key(self, basic_component):
- """Test that key property returns name when no custom key is set."""
- assert basic_component.key == "test_component"
+ """Test that key property returns name@version when no custom key is set."""
+ # Base component has no KEY_PREFIX, so key is just "name@version" (or "name@" for unversioned)
+ assert basic_component.key == "test_component@"
def test_get_meta_without_fastmcp_meta(self, basic_component):
"""Test get_meta without including fastmcp meta."""
@@ -207,14 +208,14 @@ class TestKeyPrefix:
assert Prompt.make_key("my_prompt") == "prompt:my_prompt"
def test_tool_key_property(self):
- """Test that Tool.key returns prefixed key."""
+ """Test that Tool.key returns prefixed key with version sentinel."""
tool = Tool(name="greet", description="A greeting tool", parameters={})
- assert tool.key == "tool:greet"
+ assert tool.key == "tool:greet@"
def test_prompt_key_property(self):
- """Test that Prompt.key returns prefixed key."""
+ """Test that Prompt.key returns prefixed key with version sentinel."""
prompt = Prompt(name="analyze", description="An analysis prompt")
- assert prompt.key == "prompt:analyze"
+ assert prompt.key == "prompt:analyze@"
def test_warning_for_missing_key_prefix(self):
"""Test that subclassing without KEY_PREFIX emits a warning."""
@@ -263,21 +264,21 @@ class TestComponentEnableDisable:
tool = Tool(name="my_tool", description="A tool", parameters={})
with pytest.raises(NotImplementedError) as exc_info:
tool.enable()
- assert "tool:my_tool" in str(exc_info.value)
+ assert "tool:my_tool@" in str(exc_info.value)
def test_tool_disable_raises_not_implemented(self):
"""Test that Tool.disable() raises NotImplementedError."""
tool = Tool(name="my_tool", description="A tool", parameters={})
with pytest.raises(NotImplementedError) as exc_info:
tool.disable()
- assert "tool:my_tool" in str(exc_info.value)
+ assert "tool:my_tool@" in str(exc_info.value)
def test_prompt_enable_raises_not_implemented(self):
"""Test that Prompt.enable() raises NotImplementedError."""
prompt = Prompt(name="my_prompt", description="A prompt")
with pytest.raises(NotImplementedError) as exc_info:
prompt.enable()
- assert "prompt:my_prompt" in str(exc_info.value)
+ assert "prompt:my_prompt@" in str(exc_info.value)
class TestFastMCPMeta:
@@ -369,13 +370,15 @@ class TestEdgeCasesAndIntegration:
assert updated_component.title == "New Title" # Updated
assert updated_component.description == "New Description" # Updated
assert updated_component.tags == {"tag1"} # Not in update, unchanged
- assert updated_component.key == "new_name" # .key is computed from name
+ assert (
+ updated_component.key == "new_name@"
+ ) # .key is computed from name with @ sentinel
# Original should be unchanged
assert component.name == "test"
assert component.title == "Original Title"
assert component.description == "Original Description"
- assert component.key == "test" # Uses name as key
+ assert component.key == "test@" # Uses name as key with @ sentinel
def test_model_copy_deep_parameter(self):
"""Test that model_copy respects the deep parameter."""
diff --git a/tests/utilities/test_visibility.py b/tests/utilities/test_visibility.py
index 4f745d55f..d556702c7 100644
--- a/tests/utilities/test_visibility.py
+++ b/tests/utilities/test_visibility.py
@@ -17,7 +17,7 @@ class TestVisibilityBasics:
"""Disabling by key hides the component."""
v = Visibility()
tool = Tool(name="test", parameters={})
- v.disable(keys=["tool:test"])
+ v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
def test_disable_by_tag(self):
@@ -38,9 +38,9 @@ class TestVisibilityBasics:
"""Enable removes keys/tags from blocklist."""
v = Visibility()
tool = Tool(name="test", parameters={})
- v.disable(keys=["tool:test"])
+ v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
- v.enable(keys=["tool:test"])
+ v.enable(keys=["tool:test@"])
assert v.is_enabled(tool) is True
@@ -51,14 +51,14 @@ class TestVisibilityAllowlist:
"""With only=True, non-matching components are hidden."""
v = Visibility()
tool = Tool(name="test", parameters={})
- v.enable(keys=["tool:other"], only=True)
+ v.enable(keys=["tool:other@"], only=True)
assert v.is_enabled(tool) is False
def test_only_mode_shows_matching_key(self):
"""With only=True, matching keys are shown."""
v = Visibility()
tool = Tool(name="test", parameters={})
- v.enable(keys=["tool:test"], only=True)
+ v.enable(keys=["tool:test@"], only=True)
assert v.is_enabled(tool) is True
def test_only_mode_shows_matching_tag(self):
@@ -83,8 +83,8 @@ class TestVisibilityPrecedence:
"""Blocklist key beats allowlist key."""
v = Visibility()
tool = Tool(name="test", parameters={})
- v.enable(keys=["tool:test"], only=True)
- v.disable(keys=["tool:test"])
+ v.enable(keys=["tool:test@"], only=True)
+ v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
def test_blocklist_wins_over_allowlist_tag(self):
@@ -103,7 +103,7 @@ class TestVisibilityReset:
"""Reset returns to default state."""
v = Visibility()
tool = Tool(name="test", parameters={})
- v.disable(keys=["tool:test"])
+ v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
v.reset()
assert v.is_enabled(tool) is True
@@ -112,7 +112,7 @@ class TestVisibilityReset:
"""Reset clears allowlist mode."""
v = Visibility()
tool = Tool(name="test", parameters={})
- v.enable(keys=["tool:other"], only=True)
+ v.enable(keys=["tool:other@"], only=True)
assert v.is_enabled(tool) is False
v.reset()
assert v.is_enabled(tool) is True
diff --git a/uv.lock b/uv.lock
index f0de40a00..45ec1a233 100644
--- a/uv.lock
+++ b/uv.lock
@@ -695,6 +695,7 @@ dependencies = [
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "opentelemetry-api" },
+ { name = "packaging" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
@@ -760,6 +761,7 @@ requires-dist = [
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "opentelemetry-api", specifier = ">=1.20.0" },
+ { name = "packaging", specifier = ">=24.0" },
{ name = "platformdirs", specifier = ">=4.0.0" },
{ name = "py-key-value-aio", extras = ["disk", "keyring", "memory"], specifier = ">=0.3.0,<0.4.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },