diff --git a/docs/development/upgrade-guide.mdx b/docs/development/upgrade-guide.mdx
index 18af5b7e44..c5465f1b3c 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 efd5e7deb4..8e3d9e0da9 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 671c1a9d77..5b1c59f23e 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 1ec521e90b..fb024affc2 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 a0157a68f4..5a682f16d9 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 d9ed6521d8..770d1db5af 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 0000000000..1079249b41
--- /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 62ae05c820..a2951b1e86 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 5ef9b332c4..105994464f 100644
--- a/src/fastmcp/contrib/component_manager/component_service.py
+++ b/src/fastmcp/contrib/component_manager/component_service.py
@@ -94,13 +94,20 @@ async def _enable_tool(self, name: str) -> Tool:
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 @@ async def _disable_tool(self, name: str) -> Tool:
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 @@ async def _enable_resource(self, uri: str) -> Resource | ResourceTemplate:
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 @@ async def _disable_resource(self, uri: str) -> Resource | ResourceTemplate:
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 @@ async def _enable_prompt(self, name: str) -> Prompt:
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 @@ async def _disable_prompt(self, name: str) -> Prompt:
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 0174fe42b9..4be4ae68aa 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 @@ def from_function(
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 @@ def from_function(
# 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 @@ def from_function(
if metadata is None:
metadata = PromptMeta(
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -217,6 +220,7 @@ def from_function(
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 create_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 create_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 b84668e359..fde2c9e4f0 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -231,6 +231,7 @@ def from_function(
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 @@ def from_function(
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 dab197ce8c..3d57d0dbc7 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 @@ def from_function(
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 @@ def from_function(
x is not None
for x in [
name,
+ version,
title,
description,
icons,
@@ -134,6 +137,7 @@ def from_function(
metadata = ResourceMeta(
uri=str(uri),
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -179,6 +183,7 @@ def from_function(
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 create_resource(fn: Callable[..., Any]) -> FunctionResource | ResourceTempla
resource_meta = ResourceMeta(
uri=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -280,6 +287,7 @@ def create_resource(fn: Callable[..., Any]) -> FunctionResource | ResourceTempla
fn=fn,
uri_template=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -297,6 +305,7 @@ def attach_metadata(fn: F) -> F:
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 0894996d13..5dd907348c 100644
--- a/src/fastmcp/resources/resource.py
+++ b/src/fastmcp/resources/resource.py
@@ -237,6 +237,7 @@ def from_function(
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 @@ def from_function(
fn=fn,
uri=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -380,7 +382,8 @@ def __repr__(self) -> str:
@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 40b63de716..63c77e2c3b 100644
--- a/src/fastmcp/resources/template.py
+++ b/src/fastmcp/resources/template.py
@@ -129,6 +129,7 @@ def from_function(
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 @@ def from_function(
fn=fn,
uri_template=uri_template,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -284,7 +286,8 @@ def from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplat
@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 @@ def from_function(
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 @@ def from_function(
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 9beb6a9170..f080e58d59 100644
--- a/src/fastmcp/server/providers/aggregate.py
+++ b/src/fastmcp/server/providers/aggregate.py
@@ -20,6 +20,7 @@
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 @@ def _get_first_result(
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 @@ async def list_tools(self) -> Sequence[Tool]:
)
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 @@ async def list_resources(self) -> Sequence[Resource]:
)
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 @@ async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
)
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 @@ async def list_prompts(self) -> Sequence[Prompt]:
)
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 239903cb3f..3e5dce7450 100644
--- a/src/fastmcp/server/providers/base.py
+++ b/src/fastmcp/server/providers/base.py
@@ -40,6 +40,7 @@ async def get_tool(self, name: str) -> Tool | None:
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 @@ async def base() -> Sequence[Tool]:
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 @@ async def base() -> Sequence[Resource]:
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 @@ async def base() -> Sequence[ResourceTemplate]:
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 @@ async def base() -> Sequence[Prompt]:
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.
+
+ Args:
+ name: The transformed prompt name to look up.
+ version: Optional version filter. If None, returns highest version.
+ """
- async def base(n: str) -> Prompt | None:
- return await self.get_prompt(n)
+ 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 @@ async def base(n: str) -> Prompt | None:
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 @@ def enable(
"""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 @@ def disable(
"""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 846775eb80..b7a56ab9ac 100644
--- a/src/fastmcp/server/providers/fastmcp_provider.py
+++ b/src/fastmcp/server/providers/fastmcp_provider.py
@@ -19,6 +19,7 @@
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.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 @@ def wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool:
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 @@ def wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource:
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 @@ def wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt:
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 @@ def wrap(
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 @@ async def list_tools(self) -> Sequence[Tool]:
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 @@ async def list_resources(self) -> Sequence[Resource]:
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 @@ async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
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 @@ async def list_prompts(self) -> Sequence[Prompt]:
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 18e1bfb83e..9225ff6366 100644
--- a/src/fastmcp/server/providers/filesystem.py
+++ b/src/fastmcp/server/providers/filesystem.py
@@ -39,6 +39,7 @@ def greet(name: str) -> str:
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 @@ async def list_tools(self) -> Sequence[Tool]:
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 7b6dae434b..61c2b64fa8 100644
--- a/src/fastmcp/server/providers/local_provider.py
+++ b/src/fastmcp/server/providers/local_provider.py
@@ -46,6 +46,7 @@ def greet(name: str) -> str:
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 @@ def _send_list_changed_notification(self, component: FastMCPComponent) -> None:
# 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 @@ def _add_component(self, component: _C) -> _C:
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 @@ def add_tool(self, tool: Tool | Callable[..., Any]) -> Tool:
tool = Tool.from_function(
tool,
name=meta.name,
+ version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@@ -211,9 +281,33 @@ def add_tool(self, tool: Tool | Callable[..., Any]) -> Tool:
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 @@ def add_resource(
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 @@ def add_resource(
fn=resource,
uri=meta.uri,
name=meta.name,
+ version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@@ -271,17 +367,67 @@ def add_resource(
)
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 @@ def add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt:
prompt = Prompt.from_function(
prompt,
name=meta.name,
+ version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@@ -313,9 +460,33 @@ def add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt:
)
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 @@ async def list_tools(self) -> Sequence[Tool]:
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 @@ async def list_resources(self) -> Sequence[Resource]:
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 @@ async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
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 @@ async def list_prompts(self) -> Sequence[Prompt]:
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_component(
- self, key: str
- ) -> Tool | Resource | ResourceTemplate | Prompt | None:
- """Get a component by its prefixed key.
+ async def get_prompt(
+ self, name: str, version: VersionSpec | None = None
+ ) -> Prompt | None:
+ """Get a prompt by name.
- 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 @@ def tool(
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 @@ def tool(
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 @@ def tool(
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 @@ def decorate_and_register(
tool_obj = Tool.from_function(
fn,
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -595,6 +811,7 @@ def decorate_and_register(
metadata = ToolMeta(
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -638,6 +855,7 @@ def decorate_and_register(
return partial(
self.tool,
name=tool_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -658,6 +876,7 @@ def resource(
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 @@ def decorator(fn: AnyFunction) -> Resource | ResourceTemplate | AnyFunction:
create_resource = standalone_resource(
uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -767,6 +987,7 @@ def decorator(fn: AnyFunction) -> Resource | ResourceTemplate | AnyFunction:
metadata = ResourceMeta(
uri=uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -792,6 +1013,7 @@ def prompt(
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 @@ def prompt(
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 @@ def prompt(
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 @@ def decorate_and_register(
prompt_obj = Prompt.from_function(
fn,
name=prompt_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -925,6 +1150,7 @@ def decorate_and_register(
metadata = PromptMeta(
name=prompt_name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -958,6 +1184,7 @@ def decorate_and_register(
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 38e5da241a..3f216aef0c 100644
--- a/src/fastmcp/server/providers/openapi/provider.py
+++ b/src/fastmcp/server/providers/openapi/provider.py
@@ -36,6 +36,7 @@
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 @@ async def list_tools(self) -> Sequence[Tool]:
"""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 123fe582f8..f83e8224b1 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -103,6 +103,7 @@
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 @@ def _get_all_transforms(self) -> list[Transform]:
# -------------------------------------------------------------------------
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 @@ async def base() -> Sequence[Tool]:
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 @@ async def base() -> Sequence[Resource]:
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 @@ async def base() -> Sequence[ResourceTemplate]:
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 @@ async def base() -> Sequence[Prompt]:
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 @@ def enable(
"""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 @@ def enable(
.. 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 @@ def disable(
"""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 @@ def disable(
.. 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 @@ async def get_tools(self, *, run_middleware: bool = False) -> list[Tool]:
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
- # Deduplicate by 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 @@ async def get_tools(self, *, run_middleware: bool = False) -> list[Tool]:
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[tool.key] = tool
- return list(seen.values())
-
- async def get_tool(self, name: str) -> Tool:
+ # 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, 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 @@ async def get_resources(self, *, run_middleware: bool = False) -> list[Resource]
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
- # Deduplicate by 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 @@ async def get_resources(self, *, run_middleware: bool = False) -> list[Resource]
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[resource.key] = resource
- return list(seen.values())
-
- async def get_resource(self, uri: str) -> Resource:
+ # 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, 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 @@ async def get_resource_templates(
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
- # Deduplicate by 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 @@ async def get_resource_templates(
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[template.key] = template
- return list(seen.values())
-
- async def get_resource_template(self, uri: str) -> ResourceTemplate:
+ # 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, 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 @@ async def get_prompts(self, *, run_middleware: bool = False) -> list[Prompt]:
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
- # Deduplicate by 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 @@ async def get_prompts(self, *, run_middleware: bool = False) -> list[Prompt]:
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
- seen[prompt.key] = prompt
- return list(seen.values())
-
- async def get_prompt(self, name: str) -> Prompt:
+ # 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, 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 @@ def add_tool(self, tool: Tool | Callable[..., Any]) -> Tool:
"""
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 @@ def tool(
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 @@ def tool(
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 @@ def tool(
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 @@ def my_tool(x: int) -> str:
result = self._local_provider.tool(
name_or_fn,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -2167,6 +2321,7 @@ def resource(
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 @@ async def get_weather(city: str) -> str:
inner_decorator = self._local_provider.resource(
uri,
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -2265,6 +2421,7 @@ def prompt(
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 @@ def prompt(
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 @@ def prompt(
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 @@ def another_prompt(data: str) -> list[Message]:
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 4cf4b6f687..ff23f2f6cb 100644
--- a/src/fastmcp/server/tasks/requests.py
+++ b/src/fastmcp/server/tasks/requests.py
@@ -24,6 +24,7 @@
)
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 @@
}
+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 5c1de5d34e..2afa3610bc 100644
--- a/src/fastmcp/server/transforms/__init__.py
+++ b/src/fastmcp/server/transforms/__init__.py
@@ -21,7 +21,9 @@
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 @@
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 @@ async def list_tools(self, call_next):
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 @@ async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]:
"""
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 @@ async def list_resources(self, call_next: ListResourcesNext) -> Sequence[Resourc
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 @@ async def list_resource_templates(
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 @@ async def list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt]:
"""
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 @@ async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None
"Namespace",
"ToolTransform",
"Transform",
+ "VersionFilter",
+ "VersionSpec",
"Visibility",
]
diff --git a/src/fastmcp/server/transforms/namespace.py b/src/fastmcp/server/transforms/namespace.py
index 0078683722..a9e46e418f 100644
--- a/src/fastmcp/server/transforms/namespace.py
+++ b/src/fastmcp/server/transforms/namespace.py
@@ -17,6 +17,7 @@
ListToolsNext,
Transform,
)
+from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@@ -104,12 +105,14 @@ async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]:
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 @@ async def list_resources(self, call_next: ListResourcesNext) -> Sequence[Resourc
]
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 @@ async def list_resource_templates(
]
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 @@ async def list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt]:
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 e5b95531e0..34c4b10f43 100644
--- a/src/fastmcp/server/transforms/tool_transform.py
+++ b/src/fastmcp/server/transforms/tool_transform.py
@@ -7,6 +7,7 @@
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 @@ async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]:
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 0000000000..9837d1d739
--- /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 c13aabc140..3faa3721b9 100644
--- a/src/fastmcp/server/transforms/visibility.py
+++ b/src/fastmcp/server/transforms/visibility.py
@@ -23,6 +23,7 @@
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 @@ def disable(
"""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 @@ async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]:
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 @@ async def list_resources(self, call_next: ListResourcesNext) -> Sequence[Resourc
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 @@ async def list_resource_templates(
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 @@ async def list_prompts(self, call_next: ListPromptsNext) -> Sequence[Prompt]:
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 09ce689f29..890841ce80 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 @@ def from_function(
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 @@ def from_function(
x is not None and x is not NotSet
for x in [
name,
+ version,
title,
description,
icons,
@@ -165,6 +168,7 @@ def from_function(
if metadata is None:
metadata = ToolMeta(
name=name,
+ version=version,
title=title,
description=description,
icons=icons,
@@ -228,6 +232,7 @@ def from_function(
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 create_tool(fn: Callable[..., Any], tool_name: str | None) -> FunctionTool:
# 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 create_tool(fn: Callable[..., Any], tool_name: str | None) -> FunctionTool:
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 608e345c21..f4b83247ce 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -196,6 +196,7 @@ def from_function(
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 @@ def from_function(
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 1a97ea4444..fbe37a8bbe 100644
--- a/src/fastmcp/tools/tool_transform.py
+++ b/src/fastmcp/tools/tool_transform.py
@@ -367,6 +367,7 @@ def from_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 @@ def from_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 @@ async def custom_output(**kwargs) -> ToolResult:
)
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 @@ async def custom_output(**kwargs) -> ToolResult:
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 af083dd951..b9a7f2eab9 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 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
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 @@ def make_key(cls, identifier: str) -> str:
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 @@ def __eq__(self, other: object) -> bool:
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 0000000000..42c2c480b8
--- /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 8267de137a..6452168ee8 100644
--- a/tests/client/test_notifications.py
+++ b/tests/client/test_notifications.py
@@ -91,7 +91,7 @@ def enable_target_tool(ctx: Context) -> str:
"""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 disable_target_tool(ctx: Context) -> str:
"""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 target_resource() -> str:
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 enable_target_resource(ctx: Context) -> str:
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 target_prompt() -> str:
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 enable_target_prompt(ctx: Context) -> str:
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 a7e8f4eb86..c30763bfce 100644
--- a/tests/contrib/test_component_manager.py
+++ b/tests/contrib/test_component_manager.py
@@ -79,7 +79,7 @@ def client(self, mcp):
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 @@ async def test_disable_tool_route(self, client, mcp):
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 @@ async def test_disable_resource_route(self, client, mcp):
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 @@ async def test_disable_template_route(self, client, mcp):
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 @@ async def test_disable_prompt_route(self, client, mcp):
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 @@ async def test_disable_tool_route_on_mounted_server(self, client, mounted_mcp):
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 @@ async def test_disable_resource_route_on_mounted_server(self, client, mounted_mc
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 @@ async def test_disable_template_route_on_mounted_server(self, client, mounted_mc
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 @@ def test_prompt() -> str:
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 @@ async def test_unauthorized_enable_tool(self):
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 @@ async def test_authorized_disable_tool(self):
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 @@ async def test_forbidden_enable_tool(self):
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 @@ async def test_unauthorized_disable_resource(self):
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 @@ async def test_authorized_disable_resource(self):
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 @@ async def test_unauthorized_enable_prompt(self):
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 @@ def client_with_path(self, mcp_with_path):
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 @@ async def test_disable_resource_route_with_path(
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 @@ def test_prompt() -> str:
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 @@ async def test_unauthorized_enable_tool(self):
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 @@ async def test_forbidden_enable_tool(self):
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 @@ async def test_authorized_disable_resource(self):
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 @@ async def test_unauthorized_enable_prompt(self):
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 @@ async def test_forbidden_enable_prompt(self):
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 c76e3dd1cb..db86669e9a 100644
--- a/tests/server/middleware/test_logging.py
+++ b/tests/server/middleware/test_logging.py
@@ -332,7 +332,7 @@ async def test_on_message_with_resource_template_in_payload(
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 8f325c3951..5c4d104723 100644
--- a/tests/server/providers/test_local_provider.py
+++ b/tests/server/providers/test_local_provider.py
@@ -35,8 +35,8 @@ def test_add_tool(self):
)
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 @@ def test_add_multiple_tools(self):
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 @@ def test_remove_tool(self):
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 @@ def test_add_resource(self):
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 @@ def test_resource() -> str:
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 @@ def test_add_template(self):
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 @@ def template_fn(id: str) -> str:
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 @@ def test_add_prompt(self):
)
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 @@ def test_remove_prompt(self):
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 @@ def test_tool_decorator_registers(self):
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 @@ def test_tool_decorator_with_custom_name_registers(self):
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 @@ def my_tool(x: int) -> int:
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 @@ def test_tool_enabled_false(self):
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 @@ def test_resource_decorator_registers(self):
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 @@ def test_resource_with_custom_name_registers(self):
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 @@ def test_resource_enabled_false(self):
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 @@ def test_template_enabled_false(self):
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 @@ def test_prompt_decorator_registers(self):
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 @@ def test_prompt_with_custom_name_registers(self):
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 @@ def test_prompt_enabled_false(self):
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 @@ def original_tool(x: int) -> int:
)
# 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 @@ def my_tool(x: int) -> int:
{"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 93ca93c1b7..34937c594f 100644
--- a/tests/server/providers/test_local_provider_prompts.py
+++ b/tests/server/providers/test_local_provider_prompts.py
@@ -331,12 +331,12 @@ def sample_prompt() -> str:
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 @@ async def test_prompt_disabled(self):
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 @@ async def test_prompt_toggle_enabled(self):
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 @@ async def test_prompt_toggle_disabled(self):
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 @@ def sample_prompt() -> str:
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 @@ async def test_cant_get_disabled_prompt(self):
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 95970b8312..ab4d77ec27 100644
--- a/tests/server/providers/test_local_provider_resources.py
+++ b/tests/server/providers/test_local_provider_resources.py
@@ -738,12 +738,12 @@ def sample_resource() -> str:
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 @@ async def test_resource_disabled(self):
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 @@ async def test_resource_toggle_enabled(self):
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 @@ async def test_resource_toggle_disabled(self):
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 @@ def sample_resource() -> str:
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 @@ async def test_cant_read_disabled_resource(self):
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 @@ def sample_template(param: str) -> str:
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 @@ async def test_template_disabled(self):
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 @@ async def test_template_toggle_enabled(self):
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 @@ async def test_template_toggle_disabled(self):
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 @@ def sample_template(param: str) -> str:
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 @@ async def test_cant_read_disabled_template(self):
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 e694ed7ea3..cb4ea822cc 100644
--- a/tests/server/providers/test_local_provider_tools.py
+++ b/tests/server/providers/test_local_provider_tools.py
@@ -1473,14 +1473,14 @@ def sample_tool(x: int) -> int:
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 @@ async def test_tool_disabled_via_server(self):
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 @@ async def test_tool_toggle_enabled(self):
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 @@ async def test_tool_toggle_disabled(self):
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 @@ def sample_tool(x: int) -> int:
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 @@ async def test_cant_call_disabled_tool(self):
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 fa1fe84e9b..8a7d6a2546 100644
--- a/tests/server/providers/test_transforming_provider.py
+++ b/tests/server/providers/test_transforming_provider.py
@@ -158,8 +158,8 @@ def my_tool() -> str:
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 @@ def original() -> str:
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 @@ def my_resource() -> str:
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 @@ def my_tool() -> str:
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 a361560fbc..381fd24aca 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 b175a46ad7..8bb0ec6c87 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 my_resource() -> str:
# 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 default_tool() -> str:
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 default_tool() -> str:
# 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 explicit_false_resource() -> str:
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 my_function() -> str:
# 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 my_resource_func() -> str:
# 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 my_template_func(item_id: str) -> str:
# 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 ae5e57c07f..1d110effee 100644
--- a/tests/server/telemetry/test_server_tracing.py
+++ b/tests/server/telemetry/test_server_tracing.py
@@ -40,7 +40,7 @@ def greet(name: str) -> str:
# 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 @@ def get_config() -> str:
# 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 @@ def get_user_profile(user_id: str) -> str:
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 @@ def greeting(name: str) -> str:
# 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 1bcefe8abc..ec71902b1b 100644
--- a/tests/server/test_providers.py
+++ b/tests/server/test_providers.py
@@ -14,6 +14,7 @@
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 @@ async def list_tools(self) -> list[Tool]:
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 0000000000..7742ae485a
--- /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 4492c57da2..af81e3beb7 100644
--- a/tests/tools/test_tool_transform.py
+++ b/tests/tools/test_tool_transform.py
@@ -1054,13 +1054,13 @@ def add(x: int, y: int = 10) -> int:
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 @@ def add(x: int, y: int = 10) -> int:
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 9979b09883..fa01a7dd62 100644
--- a/tests/utilities/test_components.py
+++ b/tests/utilities/test_components.py
@@ -82,8 +82,9 @@ def test_initialization_with_all_params(self):
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 @@ def test_prompt_has_prompt_prefix(self):
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 @@ def test_tool_enable_raises_not_implemented(self):
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 @@ def test_model_copy_with_update(self):
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 4f745d55f6..d556702c75 100644
--- a/tests/utilities/test_visibility.py
+++ b/tests/utilities/test_visibility.py
@@ -17,7 +17,7 @@ def test_disable_by_key(self):
"""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 @@ def test_enable_removes_from_blocklist(self):
"""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 @@ def test_only_mode_hides_by_default(self):
"""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 @@ def test_blocklist_wins_over_allowlist_key(self):
"""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 @@ def test_reset_clears_all_filters(self):
"""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 @@ def test_reset_clears_allowlist_mode(self):
"""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 15a0c1ff16..c9b3a0db33 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" },