Add component versioning and VersionFilter transform (#2894)

This commit is contained in:
Jeremiah Lowin 2026-01-16 20:53:18 -05:00 committed by GitHub
commit a6cd764b5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3143 additions and 529 deletions

View file

@ -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@"])
```
</CodeGroup>

View file

@ -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

View file

@ -129,6 +129,7 @@
"servers/storage-backends",
"servers/tasks",
"servers/telemetry",
"servers/versioning",
"servers/visibility"
]
},

View file

@ -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.
</ParamField>
<ParamField body="version" type="str | int | None">
<VersionBadge version="3.0.0" />
Optional version identifier for this prompt. See [Versioning](/servers/versioning) for details.
</ParamField>
</Card>
#### 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
<VersionBadge version="3.0.0" />
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.

View file

@ -127,9 +127,15 @@ def get_application_status() -> str:
<ParamField body="meta" type="dict[str, Any] | None">
<VersionBadge version="2.11.0" />
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.
</ParamField>
<ParamField body="version" type="str | int | None">
<VersionBadge version="3.0.0" />
Optional version identifier for this resource. See [Versioning](/servers/versioning) for details.
</ParamField>
</Card>
#### 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.
- `"ignore"`: Keeps the original resource/template and ignores the new registration attempt.
## Versioning
<VersionBadge version="3.0.0" />
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.

View file

@ -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.
</ParamField>
<ParamField body="version" type="str | int | None">
<VersionBadge version="3.0.0" />
Optional version identifier for this tool. See [Versioning](/servers/versioning) for details.
</ParamField>
</Card>
### Using with Methods
@ -1062,3 +1068,9 @@ def calculate_sum(a: int, b: int) -> int:
mcp.remove_tool("calculate_sum")
```
## Versioning
<VersionBadge version="3.0.0" />
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.

259
docs/servers/versioning.mdx Normal file
View file

@ -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'
<VersionBadge version="3.0.0" />
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")
```
<Note>
**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.
</Note>
### 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}"
```
<Note>
Clients always receive the highest version of each component. Versioning controls what the server exposes, not what clients request.
</Note>
## 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")
```

View file

@ -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",

View file

@ -94,13 +94,20 @@ class ComponentService:
name: The name of the tool to enable
Returns:
The tool that was enabled
The tool that was enabled (highest version)
"""
logger.debug("Enabling tool: %s", name)
# 1. Check local tools first. The server will have already applied its filter.
if Tool.make_key(name) in self._server._local_provider._components:
self._server.enable(keys=[Tool.make_key(name)])
# 1. Check local tools first - find ALL versions of this tool
# Keys are "tool:name@" (unversioned) or "tool:name@version" (versioned)
key_prefix = f"{Tool.make_key(name)}@"
matching_keys = [
k
for k in self._server._local_provider._components
if k == key_prefix or k.startswith(key_prefix)
]
if matching_keys:
self._server.enable(keys=matching_keys)
tool = await self._server.get_tool(name)
if tool is None:
raise NotFoundError(f"Unknown tool: {name!r}")
@ -123,17 +130,24 @@ class ComponentService:
name: The name of the tool to disable
Returns:
The tool that was disabled
The tool that was disabled (highest version)
"""
logger.debug("Disabling tool: %s", name)
# 1. Check local tools first. The server will have already applied its filter.
key = Tool.make_key(name)
if key in self._server._local_provider._components:
tool = self._server._local_provider._components[key]
if not isinstance(tool, Tool):
# 1. Check local tools first - find ALL versions of this tool
# Keys are "tool:name@" (unversioned) or "tool:name@version" (versioned)
key_prefix = f"{Tool.make_key(name)}@"
matching_keys = [
k
for k in self._server._local_provider._components
if k == key_prefix or k.startswith(key_prefix)
]
if matching_keys:
# Get the highest version tool to return
tool = await self._server.get_tool(name)
if tool is None or not isinstance(tool, Tool):
raise NotFoundError(f"Unknown tool: {name!r}")
self._server.disable(keys=[key])
self._server.disable(keys=matching_keys)
return tool
# 2. Check mounted servers via FastMCPProvider
@ -153,20 +167,32 @@ class ComponentService:
uri: The URI of the resource to enable
Returns:
The resource that was enabled
The resource that was enabled (highest version)
"""
logger.debug("Enabling resource: %s", uri)
# 1. Check local components first (try resource, then template)
resource_key = Resource.make_key(uri)
template_key = ResourceTemplate.make_key(uri)
component = self._server._local_provider._get_component(
resource_key
) or self._server._local_provider._get_component(template_key)
if component is not None:
key = resource_key if isinstance(component, Resource) else template_key
self._server.enable(keys=[key])
return component # type: ignore[return-value]
# 1. Check local components first - find ALL versions
# Keys are "resource:uri@" or "resource:uri@version" (and same for template)
resource_prefix = f"{Resource.make_key(uri)}@"
template_prefix = f"{ResourceTemplate.make_key(uri)}@"
resource_keys = [
k
for k in self._server._local_provider._components
if k == resource_prefix or k.startswith(resource_prefix)
]
template_keys = [
k
for k in self._server._local_provider._components
if k == template_prefix or k.startswith(template_prefix)
]
if resource_keys:
self._server.enable(keys=resource_keys)
resource = await self._server.get_resource(uri)
return resource
if template_keys:
self._server.enable(keys=template_keys)
template = await self._server.get_resource_template(uri)
return template
# 2. Check mounted servers via FastMCPProvider
for provider in self._server._providers:
@ -187,20 +213,34 @@ class ComponentService:
uri: The URI of the resource to disable
Returns:
The resource that was disabled
The resource that was disabled (highest version)
"""
logger.debug("Disabling resource: %s", uri)
# 1. Check local components first (try resource, then template)
resource_key = Resource.make_key(uri)
template_key = ResourceTemplate.make_key(uri)
component = self._server._local_provider._get_component(
resource_key
) or self._server._local_provider._get_component(template_key)
if component is not None:
key = resource_key if isinstance(component, Resource) else template_key
self._server.disable(keys=[key])
return component # type: ignore[return-value]
# 1. Check local components first - find ALL versions
# Keys are "resource:uri@" or "resource:uri@version" (and same for template)
resource_prefix = f"{Resource.make_key(uri)}@"
template_prefix = f"{ResourceTemplate.make_key(uri)}@"
resource_keys = [
k
for k in self._server._local_provider._components
if k == resource_prefix or k.startswith(resource_prefix)
]
template_keys = [
k
for k in self._server._local_provider._components
if k == template_prefix or k.startswith(template_prefix)
]
if resource_keys:
# Get the highest version to return before disabling
resource = await self._server.get_resource(uri)
self._server.disable(keys=resource_keys)
return resource
if template_keys:
# Get the highest version to return before disabling
template = await self._server.get_resource_template(uri)
self._server.disable(keys=template_keys)
return template
# 2. Check mounted servers via FastMCPProvider
for provider in self._server._providers:
@ -221,14 +261,20 @@ class ComponentService:
name: The name of the prompt to enable
Returns:
The prompt that was enabled
The prompt that was enabled (highest version)
"""
logger.debug("Enabling prompt: %s", name)
# 1. Check local prompts first. The server will have already applied its filter.
key = Prompt.make_key(name)
if key in self._server._local_provider._components:
self._server.enable(keys=[key])
# 1. Check local prompts first - find ALL versions of this prompt
# Keys are "prompt:name@" (unversioned) or "prompt:name@version" (versioned)
key_prefix = f"{Prompt.make_key(name)}@"
matching_keys = [
k
for k in self._server._local_provider._components
if k == key_prefix or k.startswith(key_prefix)
]
if matching_keys:
self._server.enable(keys=matching_keys)
prompt = await self._server.get_prompt(name)
if prompt is None:
raise NotFoundError(f"Unknown prompt: {name}")
@ -251,17 +297,24 @@ class ComponentService:
name: The name of the prompt to disable
Returns:
The prompt that was disabled
The prompt that was disabled (highest version)
"""
logger.debug("Disabling prompt: %s", name)
# 1. Check local prompts first. The server will have already applied its filter.
key = Prompt.make_key(name)
if key in self._server._local_provider._components:
prompt = self._server._local_provider._components[key]
if not isinstance(prompt, Prompt):
# 1. Check local prompts first - find ALL versions of this prompt
# Keys are "prompt:name@" (unversioned) or "prompt:name@version" (versioned)
key_prefix = f"{Prompt.make_key(name)}@"
matching_keys = [
k
for k in self._server._local_provider._components
if k == key_prefix or k.startswith(key_prefix)
]
if matching_keys:
# Get the highest version prompt to return
prompt = await self._server.get_prompt(name)
if prompt is None or not isinstance(prompt, Prompt):
raise NotFoundError(f"Unknown prompt: {name}")
self._server.disable(keys=[key])
self._server.disable(keys=matching_keys)
return prompt
# 2. Check mounted servers via FastMCPProvider

View file

@ -59,6 +59,7 @@ class PromptMeta:
type: Literal["prompt"] = field(default="prompt", init=False)
name: str | None = None
version: str | int | None = None
title: str | None = None
description: str | None = None
icons: list[Icon] | None = None
@ -81,6 +82,7 @@ class FunctionPrompt(Prompt):
metadata: PromptMeta | None = None,
# Keep individual params for backwards compat
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -106,7 +108,7 @@ class FunctionPrompt(Prompt):
# Check mutual exclusion
individual_params_provided = any(
x is not None
for x in [name, title, description, icons, tags, meta, task, auth]
for x in [name, version, title, description, icons, tags, meta, task, auth]
)
if metadata is not None and individual_params_provided:
@ -119,6 +121,7 @@ class FunctionPrompt(Prompt):
if metadata is None:
metadata = PromptMeta(
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -217,6 +220,7 @@ class FunctionPrompt(Prompt):
return cls(
name=func_name,
version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
description=description,
icons=metadata.icons,
@ -350,6 +354,7 @@ def prompt(fn: F) -> F: ...
def prompt(
name_or_fn: str,
*,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -363,6 +368,7 @@ def prompt(
name_or_fn: None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -377,6 +383,7 @@ def prompt(
name_or_fn: str | Callable[..., Any] | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -402,6 +409,7 @@ def prompt(
# Create metadata first, then pass it
prompt_meta = PromptMeta(
name=prompt_name,
version=version,
title=title,
description=description,
icons=icons,
@ -415,6 +423,7 @@ def prompt(
def attach_metadata(fn: F, prompt_name: str | None) -> F:
metadata = PromptMeta(
name=prompt_name,
version=version,
title=title,
description=description,
icons=icons,

View file

@ -231,6 +231,7 @@ class Prompt(FastMCPComponent):
fn: Callable[..., Any],
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -251,6 +252,7 @@ class Prompt(FastMCPComponent):
return FunctionPrompt.from_function(
fn=fn,
name=name,
version=version,
title=title,
description=description,
icons=icons,

View file

@ -46,6 +46,7 @@ class ResourceMeta:
type: Literal["resource"] = field(default="resource", init=False)
uri: str
name: str | None = None
version: str | int | None = None
title: str | None = None
description: str | None = None
icons: list[Icon] | None = None
@ -81,6 +82,7 @@ class FunctionResource(Resource):
metadata: ResourceMeta | None = None,
# Keep individual params for backwards compat
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -107,6 +109,7 @@ class FunctionResource(Resource):
x is not None
for x in [
name,
version,
title,
description,
icons,
@ -134,6 +137,7 @@ class FunctionResource(Resource):
metadata = ResourceMeta(
uri=str(uri),
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -179,6 +183,7 @@ class FunctionResource(Resource):
fn=wrapped_fn,
uri=uri_obj,
name=func_name,
version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
description=metadata.description or inspect.getdoc(fn),
icons=metadata.icons,
@ -226,6 +231,7 @@ def resource(
uri: str,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -263,6 +269,7 @@ def resource(
resource_meta = ResourceMeta(
uri=uri,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -280,6 +287,7 @@ def resource(
fn=fn,
uri_template=uri,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -297,6 +305,7 @@ def resource(
metadata = ResourceMeta(
uri=uri,
name=name,
version=version,
title=title,
description=description,
icons=icons,

View file

@ -237,6 +237,7 @@ class Resource(FastMCPComponent):
uri: str | AnyUrl,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -255,6 +256,7 @@ class Resource(FastMCPComponent):
fn=fn,
uri=uri,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -380,7 +382,8 @@ class Resource(FastMCPComponent):
@property
def key(self) -> str:
"""The globally unique lookup key for this resource."""
return self.make_key(str(self.uri))
base_key = self.make_key(str(self.uri))
return f"{base_key}@{self.version or ''}"
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution."""

View file

@ -129,6 +129,7 @@ class ResourceTemplate(FastMCPComponent):
fn: Callable[..., Any],
uri_template: str,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -143,6 +144,7 @@ class ResourceTemplate(FastMCPComponent):
fn=fn,
uri_template=uri_template,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -284,7 +286,8 @@ class ResourceTemplate(FastMCPComponent):
@property
def key(self) -> str:
"""The globally unique lookup key for this template."""
return self.make_key(self.uri_template)
base_key = self.make_key(self.uri_template)
return f"{base_key}@{self.version or ''}"
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution."""
@ -459,6 +462,7 @@ class FunctionResourceTemplate(ResourceTemplate):
fn: Callable[..., Any],
uri_template: str,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -568,6 +572,7 @@ class FunctionResourceTemplate(ResourceTemplate):
return cls(
uri_template=uri_template,
name=func_name,
version=str(version) if version is not None else None,
title=title,
description=description,
icons=icons,

View file

@ -20,6 +20,7 @@ from fastmcp.server.providers.base import Provider
from fastmcp.tools.tool import Tool
from fastmcp.utilities.async_utils import gather
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.versions import VersionSpec, version_sort_key
logger = logging.getLogger(__name__)
@ -30,10 +31,9 @@ class AggregateProvider(Provider):
"""Presents multiple providers as a single provider.
Components are aggregated from all providers. For get_* operations,
providers are queried in parallel and the first non-None result is returned.
providers are queried in parallel and the highest version is returned.
Errors from individual providers are logged and skipped (graceful degradation).
This matches the behavior of FastMCP's original provider iteration.
"""
def __init__(self, providers: Sequence[Provider]) -> None:
@ -77,6 +77,31 @@ class AggregateProvider(Provider):
return result
return None
def _get_highest_version_result(
self,
results: list[FastMCPComponent | None | BaseException],
operation: str,
) -> FastMCPComponent | None:
"""Get the highest version from successful non-None results.
Used for versioned components where we want the highest version
across all providers rather than the first match.
"""
valid: list[FastMCPComponent] = []
for i, result in enumerate(results):
if isinstance(result, BaseException):
if not isinstance(result, NotFoundError):
logger.debug(
f"Error during {operation} from provider "
f"{self._providers[i]}: {result}"
)
continue
if result is not None:
valid.append(result)
if not valid:
return None
return max(valid, key=version_sort_key)
def __repr__(self) -> str:
return f"AggregateProvider(providers={self._providers!r})"
@ -92,13 +117,21 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_tools")
async def get_tool(self, name: str) -> Tool | None:
"""Get tool by name from first provider that has it (with transforms applied)."""
async def get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get tool by name.
Args:
name: The tool name.
version: If None, returns highest version across all providers.
If specified, returns highest version matching the spec from any provider.
"""
results = await gather(
*[p._get_tool(name) for p in self._providers],
*[p._get_tool(name, version) for p in self._providers],
return_exceptions=True,
)
return self._get_first_result(results, f"get_tool({name!r})")
return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value]
# -------------------------------------------------------------------------
# Resources
@ -112,13 +145,21 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_resources")
async def get_resource(self, uri: str) -> Resource | None:
"""Get resource by URI from first provider that has it (with transforms applied)."""
async def get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get resource by URI.
Args:
uri: The resource URI.
version: If None, returns highest version across all providers.
If specified, returns highest version matching the spec from any provider.
"""
results = await gather(
*[p._get_resource(uri) for p in self._providers],
*[p._get_resource(uri, version) for p in self._providers],
return_exceptions=True,
)
return self._get_first_result(results, f"get_resource({uri!r})")
return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value]
# -------------------------------------------------------------------------
# Resource Templates
@ -132,13 +173,23 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_resource_templates")
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get resource template by URI from first provider that has it (with transforms applied)."""
async def get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get resource template by URI.
Args:
uri: The template URI to match.
version: If None, returns highest version across all providers.
If specified, returns highest version matching the spec from any provider.
"""
results = await gather(
*[p._get_resource_template(uri) for p in self._providers],
*[p._get_resource_template(uri, version) for p in self._providers],
return_exceptions=True,
)
return self._get_first_result(results, f"get_resource_template({uri!r})")
return self._get_highest_version_result(
results, f"get_resource_template({uri!r})"
) # type: ignore[return-value]
# -------------------------------------------------------------------------
# Prompts
@ -152,36 +203,21 @@ class AggregateProvider(Provider):
)
return self._collect_list_results(results, "list_prompts")
async def get_prompt(self, name: str) -> Prompt | None:
"""Get prompt by name from first provider that has it (with transforms applied)."""
async def get_prompt(
self, name: str, version: VersionSpec | None = None
) -> Prompt | None:
"""Get prompt by name.
Args:
name: The prompt name.
version: If None, returns highest version across all providers.
If specified, returns highest version matching the spec from any provider.
"""
results = await gather(
*[p._get_prompt(name) for p in self._providers],
*[p._get_prompt(name, version) for p in self._providers],
return_exceptions=True,
)
return self._get_first_result(results, f"get_prompt({name!r})")
# -------------------------------------------------------------------------
# Components
# -------------------------------------------------------------------------
async def get_component(
self, key: str
) -> Tool | Resource | ResourceTemplate | Prompt | None:
"""Get component by key from first provider that has it (with transforms applied).
Parses the key prefix and delegates to the appropriate get_* method
so that transforms are applied correctly.
"""
# Parse key prefix to route to correct method
if key.startswith("tool:"):
return await self.get_tool(key[5:])
elif key.startswith("resource:"):
return await self.get_resource(key[9:])
elif key.startswith("template:"):
return await self.get_resource_template(key[9:])
elif key.startswith("prompt:"):
return await self.get_prompt(key[7:])
return None
return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value]
# -------------------------------------------------------------------------
# Tasks

View file

@ -40,6 +40,7 @@ from fastmcp.server.transforms.visibility import Visibility
from fastmcp.tools.tool import Tool
from fastmcp.utilities.async_utils import gather
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.versions import VersionSpec, version_sort_key
if TYPE_CHECKING:
from fastmcp.server.transforms import Transform
@ -114,24 +115,27 @@ class Provider:
return await chain()
async def _get_tool(self, name: str) -> Tool | None:
async def _get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get tool by transformed name with all transforms applied.
Args:
name: The transformed tool name to look up.
version: Optional version filter. If None, returns highest version.
Returns:
The tool if found and enabled, None otherwise.
"""
async def base(n: str) -> Tool | None:
return await self.get_tool(n)
async def base(n: str, version: VersionSpec | None = None) -> Tool | None:
return await self.get_tool(n, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_tool, call_next=chain)
return await chain(name)
return await chain(name, version=version)
async def _list_resources(self) -> Sequence[Resource]:
"""List resources with all transforms applied."""
@ -145,17 +149,24 @@ class Provider:
return await chain()
async def _get_resource(self, uri: str) -> Resource | None:
"""Get resource by transformed URI with all transforms applied."""
async def _get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get resource by transformed URI with all transforms applied.
async def base(u: str) -> Resource | None:
return await self.get_resource(u)
Args:
uri: The transformed resource URI to look up.
version: Optional version filter. If None, returns highest version.
"""
async def base(u: str, version: VersionSpec | None = None) -> Resource | None:
return await self.get_resource(u, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_resource, call_next=chain)
return await chain(uri)
return await chain(uri, version=version)
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""List resource templates with all transforms applied."""
@ -169,17 +180,26 @@ class Provider:
return await chain()
async def _get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get resource template by transformed URI with all transforms applied."""
async def _get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get resource template by transformed URI with all transforms applied.
async def base(u: str) -> ResourceTemplate | None:
return await self.get_resource_template(u)
Args:
uri: The transformed template URI to look up.
version: Optional version filter. If None, returns highest version.
"""
async def base(
u: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
return await self.get_resource_template(u, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_resource_template, call_next=chain)
return await chain(uri)
return await chain(uri, version=version)
async def _list_prompts(self) -> Sequence[Prompt]:
"""List prompts with all transforms applied."""
@ -193,17 +213,24 @@ class Provider:
return await chain()
async def _get_prompt(self, name: str) -> Prompt | None:
"""Get prompt by transformed name with all transforms applied."""
async def _get_prompt(
self, name: str, version: VersionSpec | None = None
) -> Prompt | None:
"""Get prompt by transformed name with all transforms applied.
async def base(n: str) -> Prompt | None:
return await self.get_prompt(n)
Args:
name: The transformed prompt name to look up.
version: Optional version filter. If None, returns highest version.
"""
async def base(n: str, version: VersionSpec | None = None) -> Prompt | None:
return await self.get_prompt(n, version)
chain = base
for transform in self._transforms:
chain = partial(transform.get_prompt, call_next=chain)
return await chain(name)
return await chain(name, version=version)
# -------------------------------------------------------------------------
# Public list/get methods (override these to provide components)
@ -212,82 +239,127 @@ class Provider:
async def list_tools(self) -> Sequence[Tool]:
"""Return all available tools.
Override to provide tools dynamically.
Override to provide tools dynamically. Returns ALL versions of all tools.
The server handles deduplication to show one tool per name.
"""
return []
async def get_tool(self, name: str) -> Tool | None:
async def get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get a specific tool by name.
Default implementation lists all tools and finds by name.
Override for more efficient single-tool lookup.
Default implementation filters list_tools() and picks the highest version
that matches the spec.
Args:
name: The tool name.
version: Optional version filter. If None, returns highest version.
If specified, returns highest version matching the spec.
Returns:
The Tool if found, or None to continue searching other providers.
"""
tools = await self.list_tools()
return next((t for t in tools if t.name == name), None)
matching = [t for t in tools if t.name == name]
if version:
matching = [t for t in matching if version.matches(t.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resources(self) -> Sequence[Resource]:
"""Return all available resources.
Override to provide resources dynamically.
Override to provide resources dynamically. Returns ALL versions of all resources.
The server handles deduplication to show one resource per URI.
"""
return []
async def get_resource(self, uri: str) -> Resource | None:
async def get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a specific resource by URI.
Default implementation lists all resources and finds by URI.
Override for more efficient single-resource lookup.
Default implementation filters list_resources() and returns highest
version matching the spec.
Args:
uri: The resource URI.
version: Optional version filter. If None, returns highest version.
Returns:
The Resource if found, or None to continue searching other providers.
"""
resources = await self.list_resources()
return next((r for r in resources if str(r.uri) == uri), None)
matching = [r for r in resources if str(r.uri) == uri]
if version:
matching = [r for r in matching if version.matches(r.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all available resource templates.
Override to provide resource templates dynamically.
Override to provide resource templates dynamically. Returns ALL versions.
The server handles deduplication.
"""
return []
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
async def get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI.
Default implementation lists all templates and finds one whose pattern
matches the URI.
Override for more efficient lookup.
Default implementation lists all templates, finds those whose pattern
matches the URI, and returns the highest version matching the spec.
Args:
uri: The URI to match against templates.
version: Optional version filter. If None, returns highest version.
Returns:
The ResourceTemplate if a matching one is found, or None to continue searching.
"""
templates = await self.list_resource_templates()
return next(
(t for t in templates if t.matches(uri) is not None),
None,
)
matching = [t for t in templates if t.matches(uri) is not None]
if version:
matching = [t for t in matching if version.matches(t.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all available prompts.
Override to provide prompts dynamically.
Override to provide prompts dynamically. Returns ALL versions of all prompts.
The server handles deduplication to show one prompt per name.
"""
return []
async def get_prompt(self, name: str) -> Prompt | None:
async def get_prompt(
self, name: str, version: VersionSpec | None = None
) -> Prompt | None:
"""Get a specific prompt by name.
Default implementation lists all prompts and finds by name.
Override for more efficient single-prompt lookup.
Default implementation filters list_prompts() and picks the highest version
matching the spec.
Args:
name: The prompt name.
version: Optional version filter. If None, returns highest version.
Returns:
The Prompt if found, or None to continue searching other providers.
"""
prompts = await self.list_prompts()
return next((p for p in prompts if p.name == name), None)
matching = [p for p in prompts if p.name == name]
if version:
matching = [p for p in matching if version.matches(p.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
# -------------------------------------------------------------------------
# Task registration
@ -406,7 +478,7 @@ class Provider:
"""Enable components by removing from blocklist, or set allowlist with only=True.
Args:
keys: Keys to enable (e.g., "tool:my_tool").
keys: Keys to enable (e.g., "tool:my_tool@" for unversioned, "tool:my_tool@1.0" for versioned).
tags: Tags to enable - components with these tags will be enabled.
only: If True, switches to allowlist mode - ONLY show these keys/tags.
"""
@ -421,7 +493,7 @@ class Provider:
"""Disable components by adding to the blocklist.
Args:
keys: Keys to disable (e.g., "tool:my_tool").
keys: Keys to disable (e.g., "tool:my_tool@" for unversioned, "tool:my_tool@1.0" for versioned).
tags: Tags to disable - components with these tags will be disabled.
"""
self._visibility.disable(keys=keys, tags=tags)

View file

@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any, overload
import mcp.types
from mcp.types import AnyUrl
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
@ -27,6 +28,7 @@ from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.telemetry import delegate_span
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from docket import Docket
@ -79,6 +81,7 @@ class FastMCPProviderTool(Tool):
server=server,
original_name=tool.name,
name=tool.name,
version=tool.version,
description=tool.description,
parameters=tool.parameters,
output_schema=tool.output_schema,
@ -167,6 +170,7 @@ class FastMCPProviderResource(Resource):
server=server,
original_uri=str(resource.uri),
uri=resource.uri,
version=resource.version,
name=resource.name,
description=resource.description,
mime_type=resource.mime_type,
@ -231,6 +235,7 @@ class FastMCPProviderPrompt(Prompt):
server=server,
original_name=prompt.name,
name=prompt.name,
version=prompt.version,
description=prompt.description,
arguments=prompt.arguments,
tags=prompt.tags,
@ -320,6 +325,7 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
server=server,
original_uri_template=template.uri_template,
uri_template=template.uri_template,
version=template.version,
name=template.name,
description=template.description,
mime_type=template.mime_type,
@ -487,10 +493,19 @@ class FastMCPProvider(Provider):
raw_tools = await self.server.get_tools(run_middleware=True)
return [FastMCPProviderTool.wrap(self.server, t) for t in raw_tools]
async def get_tool(self, name: str) -> Tool | None:
"""Get a tool by name as a FastMCPProviderTool."""
tools = await self.list_tools()
return next((t for t in tools if t.name == name), None)
async def get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name as a FastMCPProviderTool.
Passes the full VersionSpec to the nested server, which handles both
exact version matching and range filtering.
"""
try:
raw_tool = await self.server.get_tool(name, version)
except NotFoundError:
return None
return FastMCPProviderTool.wrap(self.server, raw_tool)
# -------------------------------------------------------------------------
# Resource methods
@ -506,10 +521,19 @@ class FastMCPProvider(Provider):
raw_resources = await self.server.get_resources(run_middleware=True)
return [FastMCPProviderResource.wrap(self.server, r) for r in raw_resources]
async def get_resource(self, uri: str) -> Resource | None:
"""Get a concrete resource by URI as a FastMCPProviderResource."""
resources = await self.list_resources()
return next((r for r in resources if str(r.uri) == uri), None)
async def get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a concrete resource by URI as a FastMCPProviderResource.
Passes the full VersionSpec to the nested server, which handles both
exact version matching and range filtering.
"""
try:
raw_resource = await self.server.get_resource(uri, version)
except NotFoundError:
return None
return FastMCPProviderResource.wrap(self.server, raw_resource)
# -------------------------------------------------------------------------
# Resource template methods
@ -526,13 +550,19 @@ class FastMCPProvider(Provider):
FastMCPProviderResourceTemplate.wrap(self.server, t) for t in raw_templates
]
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
templates = await self.list_resource_templates()
for template in templates:
if template.matches(uri) is not None:
return template
return None
async def get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI.
Passes the full VersionSpec to the nested server, which handles both
exact version matching and range filtering.
"""
try:
raw_template = await self.server.get_resource_template(uri, version)
except NotFoundError:
return None
return FastMCPProviderResourceTemplate.wrap(self.server, raw_template)
# -------------------------------------------------------------------------
# Prompt methods
@ -547,10 +577,19 @@ class FastMCPProvider(Provider):
raw_prompts = await self.server.get_prompts(run_middleware=True)
return [FastMCPProviderPrompt.wrap(self.server, p) for p in raw_prompts]
async def get_prompt(self, name: str) -> Prompt | None:
"""Get a prompt by name as a FastMCPProviderPrompt."""
prompts = await self.list_prompts()
return next((p for p in prompts if p.name == name), None)
async def get_prompt(
self, name: str, version: VersionSpec | None = None
) -> Prompt | None:
"""Get a prompt by name as a FastMCPProviderPrompt.
Passes the full VersionSpec to the nested server, which handles both
exact version matching and range filtering.
"""
try:
raw_prompt = await self.server.get_prompt(name, version)
except NotFoundError:
return None
return FastMCPProviderPrompt.wrap(self.server, raw_prompt)
# -------------------------------------------------------------------------
# Task registration

View file

@ -39,6 +39,7 @@ from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.tools.tool import Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.versions import VersionSpec
logger = get_logger(__name__)
@ -178,40 +179,48 @@ class FileSystemProvider(LocalProvider):
await self._ensure_loaded()
return await super().list_tools()
async def get_tool(self, name: str) -> Tool | None:
async def get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name, reloading if in reload mode."""
await self._ensure_loaded()
return await super().get_tool(name)
return await super().get_tool(name, version)
async def list_resources(self) -> Sequence[Resource]:
"""Return all resources, reloading if in reload mode."""
await self._ensure_loaded()
return await super().list_resources()
async def get_resource(self, uri: str) -> Resource | None:
async def get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a resource by URI, reloading if in reload mode."""
await self._ensure_loaded()
return await super().get_resource(uri)
return await super().get_resource(uri, version)
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates, reloading if in reload mode."""
await self._ensure_loaded()
return await super().list_resource_templates()
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
async def get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template, reloading if in reload mode."""
await self._ensure_loaded()
return await super().get_resource_template(uri)
return await super().get_resource_template(uri, version)
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all prompts, reloading if in reload mode."""
await self._ensure_loaded()
return await super().list_prompts()
async def get_prompt(self, name: str) -> Prompt | None:
async def get_prompt(
self, name: str, version: VersionSpec | None = None
) -> Prompt | None:
"""Get a prompt by name, reloading if in reload mode."""
await self._ensure_loaded()
return await super().get_prompt(name)
return await super().get_prompt(name, version)
def __repr__(self) -> str:
return f"FileSystemProvider(root={self._root!r}, reload={self._reload})"

View file

@ -46,6 +46,7 @@ from fastmcp.tools.tool import AuthCheckCallable, Tool
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import NotSet, NotSetT
from fastmcp.utilities.versions import VersionSpec, version_sort_key
if TYPE_CHECKING:
from fastmcp.tools.tool import ToolResultSerializerType
@ -129,6 +130,71 @@ class LocalProvider(Provider):
# Storage methods
# =========================================================================
def _get_component_identity(self, component: FastMCPComponent) -> tuple[type, str]:
"""Get the identity (type, name/uri) for a component.
Returns:
A tuple of (component_type, logical_name) where logical_name is
the name for tools/prompts or URI for resources/templates.
"""
if isinstance(component, Tool):
return (Tool, component.name)
elif isinstance(component, ResourceTemplate):
return (ResourceTemplate, component.uri_template)
elif isinstance(component, Resource):
return (Resource, str(component.uri))
elif isinstance(component, Prompt):
return (Prompt, component.name)
else:
# Fall back to key without version suffix
key = component.key
base_key = key.rsplit("@", 1)[0] if "@" in key else key
return (type(component), base_key)
def _check_version_mixing(self, component: _C) -> None:
"""Check that versioned and unversioned components aren't mixed.
LocalProvider enforces a simple rule: for any given name/URI, all
registered components must either be versioned or unversioned, not both.
This prevents confusing situations where unversioned components can't
be filtered out by version filters.
Args:
component: The component being added.
Raises:
ValueError: If adding would mix versioned and unversioned components.
"""
comp_type, logical_name = self._get_component_identity(component)
is_versioned = component.version is not None
# Check all existing components of the same type and logical name
for existing in self._components.values():
if not isinstance(existing, comp_type):
continue
_, existing_name = self._get_component_identity(existing)
if existing_name != logical_name:
continue
existing_versioned = existing.version is not None
if is_versioned != existing_versioned:
type_name = comp_type.__name__.lower()
if is_versioned:
raise ValueError(
f"Cannot add versioned {type_name} {logical_name!r} "
f"(version={component.version!r}): an unversioned "
f"{type_name} with this name already exists. "
f"Either version all components or none."
)
else:
raise ValueError(
f"Cannot add unversioned {type_name} {logical_name!r}: "
f"versioned {type_name}s with this name already exist "
f"(e.g., version={existing.version!r}). "
f"Either version all components or none."
)
def _add_component(self, component: _C) -> _C:
"""Add a component to unified storage.
@ -148,6 +214,9 @@ class LocalProvider(Provider):
return existing # type: ignore[return-value]
# "replace" and "warn" fall through to add
# Check for versioned/unversioned mixing before adding
self._check_version_mixing(component)
self._components[component.key] = component
self._send_list_changed_notification(component)
return component
@ -194,6 +263,7 @@ class LocalProvider(Provider):
tool = Tool.from_function(
tool,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@ -211,9 +281,33 @@ class LocalProvider(Provider):
tool = Tool.from_function(tool)
return self._add_component(tool)
def remove_tool(self, name: str) -> None:
"""Remove a tool from this provider's storage."""
self._remove_component(Tool.make_key(name))
def remove_tool(self, name: str, version: str | None = None) -> None:
"""Remove tool(s) from this provider's storage.
Args:
name: The tool name.
version: If None, removes ALL versions. If specified, removes only that version.
Raises:
KeyError: If no matching tool is found.
"""
if version is None:
# Remove all versions
keys_to_remove = [
k
for k, c in self._components.items()
if isinstance(c, Tool) and c.name == name
]
if not keys_to_remove:
raise KeyError(f"Tool {name!r} not found")
for key in keys_to_remove:
self._remove_component(key)
else:
# Remove specific version - key format is "tool:name@version"
key = f"{Tool.make_key(name)}@{version}"
if key not in self._components:
raise KeyError(f"Tool {name!r} version {version!r} not found")
self._remove_component(key)
def add_resource(
self, resource: Resource | ResourceTemplate | Callable[..., Any]
@ -239,6 +333,7 @@ class LocalProvider(Provider):
fn=resource,
uri_template=meta.uri,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@ -254,6 +349,7 @@ class LocalProvider(Provider):
fn=resource,
uri=meta.uri,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@ -271,17 +367,67 @@ class LocalProvider(Provider):
)
return self._add_component(resource)
def remove_resource(self, uri: str) -> None:
"""Remove a resource from this provider's storage."""
self._remove_component(Resource.make_key(uri))
def remove_resource(self, uri: str, version: str | None = None) -> None:
"""Remove resource(s) from this provider's storage.
Args:
uri: The resource URI.
version: If None, removes ALL versions. If specified, removes only that version.
Raises:
KeyError: If no matching resource is found.
"""
if version is None:
# Remove all versions
keys_to_remove = [
k
for k, c in self._components.items()
if isinstance(c, Resource) and str(c.uri) == uri
]
if not keys_to_remove:
raise KeyError(f"Resource {uri!r} not found")
for key in keys_to_remove:
self._remove_component(key)
else:
# Remove specific version
key = f"{Resource.make_key(uri)}@{version}"
if key not in self._components:
raise KeyError(f"Resource {uri!r} version {version!r} not found")
self._remove_component(key)
def add_template(self, template: ResourceTemplate) -> ResourceTemplate:
"""Add a resource template to this provider's storage."""
return self._add_component(template)
def remove_template(self, uri_template: str) -> None:
"""Remove a resource template from this provider's storage."""
self._remove_component(ResourceTemplate.make_key(uri_template))
def remove_template(self, uri_template: str, version: str | None = None) -> None:
"""Remove resource template(s) from this provider's storage.
Args:
uri_template: The template URI pattern.
version: If None, removes ALL versions. If specified, removes only that version.
Raises:
KeyError: If no matching template is found.
"""
if version is None:
# Remove all versions
keys_to_remove = [
k
for k, c in self._components.items()
if isinstance(c, ResourceTemplate) and c.uri_template == uri_template
]
if not keys_to_remove:
raise KeyError(f"Template {uri_template!r} not found")
for key in keys_to_remove:
self._remove_component(key)
else:
# Remove specific version
key = f"{ResourceTemplate.make_key(uri_template)}@{version}"
if key not in self._components:
raise KeyError(
f"Template {uri_template!r} version {version!r} not found"
)
self._remove_component(key)
def add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt:
"""Add a prompt to this provider's storage.
@ -298,6 +444,7 @@ class LocalProvider(Provider):
prompt = Prompt.from_function(
prompt,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@ -313,9 +460,33 @@ class LocalProvider(Provider):
)
return self._add_component(prompt)
def remove_prompt(self, name: str) -> None:
"""Remove a prompt from this provider's storage."""
self._remove_component(Prompt.make_key(name))
def remove_prompt(self, name: str, version: str | None = None) -> None:
"""Remove prompt(s) from this provider's storage.
Args:
name: The prompt name.
version: If None, removes ALL versions. If specified, removes only that version.
Raises:
KeyError: If no matching prompt is found.
"""
if version is None:
# Remove all versions
keys_to_remove = [
k
for k, c in self._components.items()
if isinstance(c, Prompt) and c.name == name
]
if not keys_to_remove:
raise KeyError(f"Prompt {name!r} not found")
for key in keys_to_remove:
self._remove_component(key)
else:
# Remove specific version
key = f"{Prompt.make_key(name)}@{version}"
if key not in self._components:
raise KeyError(f"Prompt {name!r} version {version!r} not found")
self._remove_component(key)
# =========================================================================
# Provider interface implementation
@ -329,16 +500,25 @@ class LocalProvider(Provider):
if isinstance(v, Tool) and self._is_component_enabled(v)
]
async def get_tool(self, name: str) -> Tool | None:
"""Get a tool by name."""
tool = self._get_component(Tool.make_key(name))
if (
tool is not None
and isinstance(tool, Tool)
and self._is_component_enabled(tool)
):
return tool
return None
async def get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name.
Args:
name: The tool name.
version: Optional version filter. If None, returns highest version.
"""
matching = [
v
for v in self._components.values()
if isinstance(v, Tool) and v.name == name and self._is_component_enabled(v)
]
if version:
matching = [t for t in matching if version.matches(t.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resources(self) -> Sequence[Resource]:
"""Return all visible resources."""
@ -348,12 +528,27 @@ class LocalProvider(Provider):
if isinstance(v, Resource) and self._is_component_enabled(v)
]
async def get_resource(self, uri: str) -> Resource | None:
"""Get a resource by URI if visible."""
component = self._components.get(Resource.make_key(uri))
if isinstance(component, Resource) and self._is_component_enabled(component):
return component
return None
async def get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a resource by URI.
Args:
uri: The resource URI.
version: Optional version filter. If None, returns highest version.
"""
matching = [
v
for v in self._components.values()
if isinstance(v, Resource)
and str(v.uri) == uri
and self._is_component_enabled(v)
]
if version:
matching = [r for r in matching if version.matches(r.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all visible resource templates."""
@ -363,16 +558,30 @@ class LocalProvider(Provider):
if isinstance(v, ResourceTemplate) and self._is_component_enabled(v)
]
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI if visible."""
for component in self._components.values():
async def get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI.
Args:
uri: The URI to match against templates.
version: Optional version filter. If None, returns highest version.
"""
# Find all templates that match the URI
matching = [
component
for component in self._components.values()
if (
isinstance(component, ResourceTemplate)
and component.matches(uri) is not None
and self._is_component_enabled(component)
):
return component
return None
)
]
if version:
matching = [t for t in matching if version.matches(t.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_prompts(self) -> Sequence[Prompt]:
"""Return all visible prompts."""
@ -382,24 +591,27 @@ class LocalProvider(Provider):
if isinstance(v, Prompt) and self._is_component_enabled(v)
]
async def get_prompt(self, name: str) -> Prompt | None:
"""Get a prompt by name if visible."""
component = self._components.get(Prompt.make_key(name))
if isinstance(component, Prompt) and self._is_component_enabled(component):
return component
return None
async def get_prompt(
self, name: str, version: VersionSpec | None = None
) -> Prompt | None:
"""Get a prompt by name.
async def get_component(
self, key: str
) -> Tool | Resource | ResourceTemplate | Prompt | None:
"""Get a component by its prefixed key.
Efficient O(1) lookup in the unified components dict.
Args:
name: The prompt name.
version: Optional version filter. If None, returns highest version.
"""
component = self._get_component(key)
if component and self._is_component_enabled(component):
return component # type: ignore[return-value]
return None
matching = [
v
for v in self._components.values()
if isinstance(v, Prompt)
and v.name == name
and self._is_component_enabled(v)
]
if version:
matching = [p for p in matching if version.matches(p.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
# =========================================================================
# Task registration
@ -424,6 +636,7 @@ class LocalProvider(Provider):
name_or_fn: AnyFunction,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -445,6 +658,7 @@ class LocalProvider(Provider):
name_or_fn: str | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -469,6 +683,7 @@ class LocalProvider(Provider):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -573,6 +788,7 @@ class LocalProvider(Provider):
tool_obj = Tool.from_function(
fn,
name=tool_name,
version=version,
title=title,
description=description,
icons=icons,
@ -595,6 +811,7 @@ class LocalProvider(Provider):
metadata = ToolMeta(
name=tool_name,
version=version,
title=title,
description=description,
icons=icons,
@ -638,6 +855,7 @@ class LocalProvider(Provider):
return partial(
self.tool,
name=tool_name,
version=version,
title=title,
description=description,
icons=icons,
@ -658,6 +876,7 @@ class LocalProvider(Provider):
uri: str,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -741,6 +960,7 @@ class LocalProvider(Provider):
create_resource = standalone_resource(
uri,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -767,6 +987,7 @@ class LocalProvider(Provider):
metadata = ResourceMeta(
uri=uri,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -792,6 +1013,7 @@ class LocalProvider(Provider):
name_or_fn: AnyFunction,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -808,6 +1030,7 @@ class LocalProvider(Provider):
name_or_fn: str | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -823,6 +1046,7 @@ class LocalProvider(Provider):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -908,6 +1132,7 @@ class LocalProvider(Provider):
prompt_obj = Prompt.from_function(
fn,
name=prompt_name,
version=version,
title=title,
description=description,
icons=icons,
@ -925,6 +1150,7 @@ class LocalProvider(Provider):
metadata = PromptMeta(
name=prompt_name,
version=version,
title=title,
description=description,
icons=icons,
@ -958,6 +1184,7 @@ class LocalProvider(Provider):
return partial(
self.prompt,
name=prompt_name,
version=version,
title=title,
description=description,
icons=icons,

View file

@ -36,6 +36,7 @@ from fastmcp.utilities.openapi import (
parse_openapi_to_http_routes,
)
from fastmcp.utilities.openapi.director import RequestDirector
from fastmcp.utilities.versions import VersionSpec, version_sort_key
__all__ = [
"OpenAPIProvider",
@ -352,28 +353,48 @@ class OpenAPIProvider(Provider):
"""Return all tools created from the OpenAPI spec."""
return list(self._tools.values())
async def get_tool(self, name: str) -> Tool | None:
async def get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name."""
return self._tools.get(name)
tool = self._tools.get(name)
if tool is None:
return None
if version is not None and not version.matches(tool.version):
return None
return tool
async def list_resources(self) -> Sequence[Resource]:
"""Return all resources created from the OpenAPI spec."""
return list(self._resources.values())
async def get_resource(self, uri: str) -> Resource | None:
async def get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get a resource by URI."""
return self._resources.get(uri)
resource = self._resources.get(uri)
if resource is None:
return None
if version is not None and not version.matches(resource.version):
return None
return resource
async def list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""Return all resource templates created from the OpenAPI spec."""
return list(self._templates.values())
async def get_resource_template(self, uri: str) -> ResourceTemplate | None:
async def get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get a resource template that matches the given URI."""
return next(
(t for t in self._templates.values() if t.matches(uri) is not None),
None,
)
matching = [t for t in self._templates.values() if t.matches(uri) is not None]
if not matching:
return None
if version is not None:
matching = [t for t in matching if version.matches(t.version)]
if not matching:
return None
return max(matching, key=version_sort_key) # type: ignore[type-var]
async def list_prompts(self) -> Sequence[Prompt]:
"""Return empty list - OpenAPI doesn't create prompts."""

View file

@ -103,6 +103,7 @@ from fastmcp.utilities.cli import log_server_banner
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.logging import get_logger, temporary_log_level
from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT
from fastmcp.utilities.versions import VersionSpec, version_sort_key
if TYPE_CHECKING:
from docket import Docket
@ -825,7 +826,11 @@ class FastMCP(Generic[LifespanResultT]):
# -------------------------------------------------------------------------
async def _source_list_tools(self) -> Sequence[Tool]:
"""List tools with all transforms applied (provider + server)."""
"""List tools with all transforms applied (provider + server).
Returns all versions of all tools. Caller is responsible for deduplication
if showing to clients (keeping highest version per name).
"""
root = self._get_root_provider()
async def base() -> Sequence[Tool]:
@ -837,21 +842,32 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
async def _source_get_tool(self, name: str) -> Tool | None:
"""Get tool by name with all transforms applied (provider + server)."""
async def _source_get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
"""Get tool by name with all transforms applied (provider + server).
Args:
name: The tool name.
version: Optional version filter. If None, returns highest version.
"""
root = self._get_root_provider()
async def base(n: str) -> Tool | None:
return await root.get_tool(n)
async def base(n: str, version: VersionSpec | None = None) -> Tool | None:
return await root._get_tool(n, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_tool, call_next=chain)
return await chain(name)
return await chain(name, version=version)
async def _source_list_resources(self) -> Sequence[Resource]:
"""List resources with all transforms applied (provider + server)."""
"""List resources with all transforms applied (provider + server).
Returns all versions of all resources. Caller is responsible for deduplication
if showing to clients (keeping highest version per URI).
"""
root = self._get_root_provider()
async def base() -> Sequence[Resource]:
@ -863,21 +879,32 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
async def _source_get_resource(self, uri: str) -> Resource | None:
"""Get resource by URI with all transforms applied (provider + server)."""
async def _source_get_resource(
self, uri: str, version: VersionSpec | None = None
) -> Resource | None:
"""Get resource by URI with all transforms applied (provider + server).
Args:
uri: The resource URI.
version: Optional version filter. If None, returns highest version.
"""
root = self._get_root_provider()
async def base(u: str) -> Resource | None:
return await root.get_resource(u)
async def base(u: str, version: VersionSpec | None = None) -> Resource | None:
return await root._get_resource(u, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_resource, call_next=chain)
return await chain(uri)
return await chain(uri, version=version)
async def _source_list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""List resource templates with all transforms applied (provider + server)."""
"""List resource templates with all transforms applied (provider + server).
Returns all versions of all templates. Caller is responsible for deduplication
if showing to clients (keeping highest version per uri_template).
"""
root = self._get_root_provider()
async def base() -> Sequence[ResourceTemplate]:
@ -889,21 +916,34 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
async def _source_get_resource_template(self, uri: str) -> ResourceTemplate | None:
"""Get resource template by URI with all transforms applied (provider + server)."""
async def _source_get_resource_template(
self, uri: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
"""Get resource template by URI with all transforms applied (provider + server).
Args:
uri: The template URI to match.
version: Optional version filter. If None, returns highest version.
"""
root = self._get_root_provider()
async def base(u: str) -> ResourceTemplate | None:
return await root.get_resource_template(u)
async def base(
u: str, version: VersionSpec | None = None
) -> ResourceTemplate | None:
return await root._get_resource_template(u, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_resource_template, call_next=chain)
return await chain(uri)
return await chain(uri, version=version)
async def _source_list_prompts(self) -> Sequence[Prompt]:
"""List prompts with all transforms applied (provider + server)."""
"""List prompts with all transforms applied (provider + server).
Returns all versions of all prompts. Caller is responsible for deduplication
if showing to clients (keeping highest version per name).
"""
root = self._get_root_provider()
async def base() -> Sequence[Prompt]:
@ -915,18 +955,25 @@ class FastMCP(Generic[LifespanResultT]):
return await chain()
async def _source_get_prompt(self, name: str) -> Prompt | None:
"""Get prompt by name with all transforms applied (provider + server)."""
async def _source_get_prompt(
self, name: str, version: VersionSpec | None = None
) -> Prompt | None:
"""Get prompt by name with all transforms applied (provider + server).
Args:
name: The prompt name.
version: Optional version filter. If None, returns highest version.
"""
root = self._get_root_provider()
async def base(n: str) -> Prompt | None:
return await root.get_prompt(n)
async def base(n: str, version: VersionSpec | None = None) -> Prompt | None:
return await root._get_prompt(n, version)
chain = base
for transform in self._get_all_transforms():
chain = partial(transform.get_prompt, call_next=chain)
return await chain(name)
return await chain(name, version=version)
async def _source_get_tasks(self) -> Sequence[FastMCPComponent]:
"""Get tasks with all transforms applied (provider + server).
@ -1049,7 +1096,7 @@ class FastMCP(Generic[LifespanResultT]):
"""Enable components by removing from blocklist, or set allowlist with only=True.
Args:
keys: Keys to enable (e.g., ``"tool:my_tool"``).
keys: Keys to enable (e.g., ``"tool:my_tool@"`` for unversioned, ``"tool:my_tool@1.0"`` for versioned).
tags: Tags to enable - components with these tags will be enabled.
only: If True, switches to allowlist mode - ONLY show these keys/tags.
This clears existing allowlists and sets default visibility to False.
@ -1064,7 +1111,7 @@ class FastMCP(Generic[LifespanResultT]):
.. code-block:: python
# By key (prefixed)
server.enable(keys=["tool:my_tool"])
server.enable(keys=["tool:my_tool@"])
# By tag
server.enable(tags={"internal"})
@ -1083,7 +1130,7 @@ class FastMCP(Generic[LifespanResultT]):
"""Disable components by adding to the blocklist.
Args:
keys: Keys to disable (e.g., ``"tool:my_tool"``).
keys: Keys to disable (e.g., ``"tool:my_tool@"`` for unversioned, ``"tool:my_tool@1.0"`` for versioned).
tags: Tags to disable - components with these tags will be disabled.
Note:
@ -1096,7 +1143,7 @@ class FastMCP(Generic[LifespanResultT]):
.. code-block:: python
# By key (prefixed)
server.disable(keys=["tool:my_tool"])
server.disable(keys=["tool:my_tool@"])
# By tag
server.disable(tags={"dangerous", "internal"})
@ -1139,11 +1186,9 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by key (first wins) and apply authorization checks
seen: dict[str, Tool] = {}
# Deduplicate by name (keeping highest version) and apply authorization checks
by_name: dict[str, Tool] = {}
for tool in tools:
if tool.key in seen:
continue
# Check tool-level auth (skip for STDIO)
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
@ -1153,25 +1198,54 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
seen[tool.key] = tool
return list(seen.values())
# Keep highest version per name
existing = by_name.get(tool.name)
if existing is None or version_sort_key(tool) > version_sort_key(existing):
by_name[tool.name] = tool
return list(by_name.values())
async def get_tool(self, name: str) -> Tool:
async def get_tool(
self, name: str, version: VersionSpec | str | None = None
) -> Tool:
"""Get an enabled tool by name.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
Args:
name: The tool name.
version: Version filter. Can be:
- None: returns highest version
- str: returns exact version match
- VersionSpec: returns best match within spec (highest matching)
"""
tool = await self._source_get_tool(name)
# Convert string to VersionSpec for backward compatibility
if isinstance(version, str):
version_spec: VersionSpec | None = VersionSpec(eq=version)
else:
version_spec = version
tool = await self._source_get_tool(name, version_spec)
if tool is None:
raise NotFoundError(f"Unknown tool: {name!r}")
if version is None:
raise NotFoundError(f"Unknown tool: {name!r}")
elif isinstance(version, str):
raise NotFoundError(f"Unknown tool: {name!r} version {version!r}")
else:
raise NotFoundError(f"Unknown tool: {name!r} matching {version!r}")
# Check tool-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and tool.auth is not None:
ctx = AuthContext(token=token, component=tool)
if not run_auth_checks(tool.auth, ctx):
raise NotFoundError(f"Unknown tool: {name!r}")
if version is None:
raise NotFoundError(f"Unknown tool: {name!r}")
elif isinstance(version, str):
raise NotFoundError(f"Unknown tool: {name!r} version {version!r}")
else:
raise NotFoundError(f"Unknown tool: {name!r} matching {version!r}")
return tool
@ -1209,11 +1283,9 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by key (first wins) and apply authorization checks
seen: dict[str, Resource] = {}
# Deduplicate by URI (keeping highest version) and apply authorization checks
by_uri: dict[str, Resource] = {}
for resource in resources:
if resource.key in seen:
continue
# Check resource-level auth (skip for STDIO)
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
@ -1223,25 +1295,57 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
seen[resource.key] = resource
return list(seen.values())
# Keep highest version per URI
uri_str = str(resource.uri)
existing = by_uri.get(uri_str)
if existing is None or version_sort_key(resource) > version_sort_key(
existing
):
by_uri[uri_str] = resource
return list(by_uri.values())
async def get_resource(self, uri: str) -> Resource:
async def get_resource(
self, uri: str, version: VersionSpec | str | None = None
) -> Resource:
"""Get an enabled resource by URI.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
Args:
uri: The resource URI.
version: Version filter. Can be:
- None: returns highest version
- str: returns exact version match
- VersionSpec: returns best match within spec (highest matching)
"""
resource = await self._source_get_resource(uri)
# Convert string to VersionSpec for backward compatibility
if isinstance(version, str):
version_spec: VersionSpec | None = VersionSpec(eq=version)
else:
version_spec = version
resource = await self._source_get_resource(uri, version_spec)
if resource is None:
raise NotFoundError(f"Unknown resource: {uri}")
if version is None:
raise NotFoundError(f"Unknown resource: {uri}")
elif isinstance(version, str):
raise NotFoundError(f"Unknown resource: {uri} version {version!r}")
else:
raise NotFoundError(f"Unknown resource: {uri} matching {version!r}")
# Check resource-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and resource.auth is not None:
ctx = AuthContext(token=token, component=resource)
if not run_auth_checks(resource.auth, ctx):
raise NotFoundError(f"Unknown resource: {uri}")
if version is None:
raise NotFoundError(f"Unknown resource: {uri}")
elif isinstance(version, str):
raise NotFoundError(f"Unknown resource: {uri} version {version!r}")
else:
raise NotFoundError(f"Unknown resource: {uri} matching {version!r}")
return resource
@ -1281,11 +1385,9 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by key (first wins) and apply authorization checks
seen: dict[str, ResourceTemplate] = {}
# Deduplicate by uri_template (keeping highest version) and apply authorization checks
by_uri_template: dict[str, ResourceTemplate] = {}
for template in templates:
if template.key in seen:
continue
# Check template-level auth (skip for STDIO)
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
@ -1295,25 +1397,64 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
seen[template.key] = template
return list(seen.values())
# Keep highest version per uri_template
existing = by_uri_template.get(template.uri_template)
if existing is None or version_sort_key(template) > version_sort_key(
existing
):
by_uri_template[template.uri_template] = template
return list(by_uri_template.values())
async def get_resource_template(self, uri: str) -> ResourceTemplate:
async def get_resource_template(
self, uri: str, version: VersionSpec | str | None = None
) -> ResourceTemplate:
"""Get an enabled resource template that matches the given URI.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
Args:
uri: The template URI to match.
version: Version filter. Can be:
- None: returns highest version
- str: returns exact version match
- VersionSpec: returns best match within spec (highest matching)
"""
template = await self._source_get_resource_template(uri)
# Convert string to VersionSpec for backward compatibility
if isinstance(version, str):
version_spec: VersionSpec | None = VersionSpec(eq=version)
else:
version_spec = version
template = await self._source_get_resource_template(uri, version_spec)
if template is None:
raise NotFoundError(f"Unknown resource template: {uri}")
if version is None:
raise NotFoundError(f"Unknown resource template: {uri}")
elif isinstance(version, str):
raise NotFoundError(
f"Unknown resource template: {uri} version {version!r}"
)
else:
raise NotFoundError(
f"Unknown resource template: {uri} matching {version!r}"
)
# Check template-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and template.auth is not None:
ctx = AuthContext(token=token, component=template)
if not run_auth_checks(template.auth, ctx):
raise NotFoundError(f"Unknown resource template: {uri}")
if version is None:
raise NotFoundError(f"Unknown resource template: {uri}")
elif isinstance(version, str):
raise NotFoundError(
f"Unknown resource template: {uri} version {version!r}"
)
else:
raise NotFoundError(
f"Unknown resource template: {uri} matching {version!r}"
)
return template
@ -1351,11 +1492,9 @@ class FastMCP(Generic[LifespanResultT]):
# Get auth context (skip_auth=True for STDIO which has no auth concept)
skip_auth, token = _get_auth_context()
# Deduplicate by key (first wins) and apply authorization checks
seen: dict[str, Prompt] = {}
# Deduplicate by name (keeping highest version) and apply authorization checks
by_name: dict[str, Prompt] = {}
for prompt in prompts:
if prompt.key in seen:
continue
# Check prompt-level auth (skip for STDIO)
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
@ -1365,55 +1504,61 @@ class FastMCP(Generic[LifespanResultT]):
except AuthorizationError:
# Treat auth errors as denials in list operations
continue
seen[prompt.key] = prompt
return list(seen.values())
# Keep highest version per name
existing = by_name.get(prompt.name)
if existing is None or version_sort_key(prompt) > version_sort_key(
existing
):
by_name[prompt.name] = prompt
return list(by_name.values())
async def get_prompt(self, name: str) -> Prompt:
async def get_prompt(
self, name: str, version: VersionSpec | str | None = None
) -> Prompt:
"""Get an enabled prompt by name.
Queries providers with full transform chain (provider transforms + server transforms + visibility).
Returns only if enabled and authorized.
Args:
name: The prompt name.
version: Version filter. Can be:
- None: returns highest version
- str: returns exact version match
- VersionSpec: returns best match within spec (highest matching)
"""
prompt = await self._source_get_prompt(name)
# Convert string to VersionSpec for backward compatibility
if isinstance(version, str):
version_spec: VersionSpec | None = VersionSpec(eq=version)
else:
version_spec = version
prompt = await self._source_get_prompt(name, version_spec)
if prompt is None:
raise NotFoundError(f"Unknown prompt: {name}")
if version is None:
raise NotFoundError(f"Unknown prompt: {name}")
elif isinstance(version, str):
raise NotFoundError(f"Unknown prompt: {name!r} version {version!r}")
else:
raise NotFoundError(f"Unknown prompt: {name!r} matching {version!r}")
# Check prompt-level auth (skip for STDIO)
skip_auth, token = _get_auth_context()
if not skip_auth and prompt.auth is not None:
ctx = AuthContext(token=token, component=prompt)
if not run_auth_checks(prompt.auth, ctx):
raise NotFoundError(f"Unknown prompt: {name}")
if version is None:
raise NotFoundError(f"Unknown prompt: {name}")
elif isinstance(version, str):
raise NotFoundError(f"Unknown prompt: {name!r} version {version!r}")
else:
raise NotFoundError(
f"Unknown prompt: {name!r} matching {version!r}"
)
return prompt
async def get_component(
self, key: str
) -> Tool | Resource | ResourceTemplate | Prompt:
"""Get a component by its prefixed key.
Routes to the appropriate get_* method which applies server-level layers.
Args:
key: The prefixed key (e.g., "tool:name", "resource:uri", "template:uri").
Returns:
The component if found.
Raises:
NotFoundError: If no component is found with the given key.
"""
# Parse key and delegate to specific methods which apply layers
if key.startswith("tool:"):
return await self.get_tool(key[5:])
elif key.startswith("resource:"):
return await self.get_resource(key[9:])
elif key.startswith("template:"):
return await self.get_resource_template(key[9:])
elif key.startswith("prompt:"):
return await self.get_prompt(key[7:])
raise NotFoundError(f"Unknown component: {key}")
@overload
async def call_tool(
self,
@ -1997,19 +2142,24 @@ class FastMCP(Generic[LifespanResultT]):
"""
return self._local_provider.add_tool(tool)
def remove_tool(self, name: str) -> None:
"""Remove a tool from the server.
def remove_tool(self, name: str, version: str | None = None) -> None:
"""Remove tool(s) from the server.
Args:
name: The name of the tool to remove
name: The name of the tool to remove.
version: If None, removes ALL versions. If specified, removes only that version.
Raises:
NotFoundError: If the tool is not found
NotFoundError: If no matching tool is found.
"""
try:
self._local_provider.remove_tool(name)
self._local_provider.remove_tool(name, version)
except KeyError:
raise NotFoundError(f"Tool {name!r} not found") from None
if version is None:
raise NotFoundError(f"Tool {name!r} not found") from None
raise NotFoundError(
f"Tool {name!r} version {version!r} not found"
) from None
@overload
def tool(
@ -2017,6 +2167,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: AnyFunction,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -2036,6 +2187,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -2054,6 +2206,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -2122,6 +2275,7 @@ class FastMCP(Generic[LifespanResultT]):
result = self._local_provider.tool(
name_or_fn,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -2167,6 +2321,7 @@ class FastMCP(Generic[LifespanResultT]):
uri: str,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -2232,6 +2387,7 @@ class FastMCP(Generic[LifespanResultT]):
inner_decorator = self._local_provider.resource(
uri,
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -2265,6 +2421,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: AnyFunction,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -2280,6 +2437,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -2294,6 +2452,7 @@ class FastMCP(Generic[LifespanResultT]):
name_or_fn: str | AnyFunction | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[mcp.types.Icon] | None = None,
@ -2378,6 +2537,7 @@ class FastMCP(Generic[LifespanResultT]):
return self._local_provider.prompt(
name_or_fn,
name=name,
version=version,
title=title,
description=description,
icons=icons,

View file

@ -24,6 +24,7 @@ from mcp.types import (
)
import fastmcp.server.context
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
@ -47,6 +48,25 @@ DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
}
def _parse_key_version(key_suffix: str) -> tuple[str, str | None]:
"""Parse a key suffix into (name_or_uri, version).
Keys always contain @ as a version delimiter (sentinel pattern):
- "add@1.0" ("add", "1.0") # versioned
- "add@" ("add", None) # unversioned
- "user@example.com@1.0" ("user@example.com", "1.0") # @ in URI
Uses rsplit to split on the LAST @ which is always the version delimiter.
Falls back to treating the whole string as the name if @ is not present
(for backwards compatibility with legacy task keys).
"""
if "@" not in key_suffix:
# Legacy key without version sentinel - treat as unversioned
return key_suffix, None
name_or_uri, version = key_suffix.rsplit("@", 1)
return name_or_uri, version if version else None
async def _lookup_task_execution(
docket: Any,
session_id: str,
@ -289,8 +309,31 @@ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
key_parts = parse_task_key(task_key)
component_key = key_parts["component_identifier"]
# Look up component by its prefixed key
component = await server.get_component(component_key)
# Look up component by its prefixed key (inlined from deleted get_component)
component: Tool | Resource | ResourceTemplate | Prompt | None = None
try:
if component_key.startswith("tool:"):
name, version = _parse_key_version(component_key[5:])
component = await server.get_tool(name, version)
elif component_key.startswith("resource:"):
uri, version = _parse_key_version(component_key[9:])
component = await server.get_resource(uri, version)
elif component_key.startswith("template:"):
uri, version = _parse_key_version(component_key[9:])
component = await server.get_resource_template(uri, version)
elif component_key.startswith("prompt:"):
name, version = _parse_key_version(component_key[7:])
component = await server.get_prompt(name, version)
except NotFoundError:
component = None
if component is None:
raise McpError(
ErrorData(
code=INTERNAL_ERROR,
message=f"Component not found for task: {component_key}",
)
)
# Build related-task metadata
related_task_meta = {

View file

@ -21,7 +21,9 @@ Example:
from __future__ import annotations
from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Protocol
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@ -30,17 +32,44 @@ if TYPE_CHECKING:
from fastmcp.tools.tool import Tool
# Type aliases for call_next signatures
# List methods are simple callables
ListToolsNext = Callable[[], Awaitable[Sequence["Tool"]]]
GetToolNext = Callable[[str], Awaitable["Tool | None"]]
ListResourcesNext = Callable[[], Awaitable[Sequence["Resource"]]]
GetResourceNext = Callable[[str], Awaitable["Resource | None"]]
ListResourceTemplatesNext = Callable[[], Awaitable[Sequence["ResourceTemplate"]]]
GetResourceTemplateNext = Callable[[str], Awaitable["ResourceTemplate | None"]]
ListPromptsNext = Callable[[], Awaitable[Sequence["Prompt"]]]
GetPromptNext = Callable[[str], Awaitable["Prompt | None"]]
# Get methods use Protocol to express keyword-only version parameter
class GetToolNext(Protocol):
"""Protocol for get_tool call_next functions."""
def __call__(
self, name: str, *, version: VersionSpec | None = None
) -> Awaitable[Tool | None]: ...
class GetResourceNext(Protocol):
"""Protocol for get_resource call_next functions."""
def __call__(
self, uri: str, *, version: VersionSpec | None = None
) -> Awaitable[Resource | None]: ...
class GetResourceTemplateNext(Protocol):
"""Protocol for get_resource_template call_next functions."""
def __call__(
self, uri: str, *, version: VersionSpec | None = None
) -> Awaitable[ResourceTemplate | None]: ...
class GetPromptNext(Protocol):
"""Protocol for get_prompt call_next functions."""
def __call__(
self, name: str, *, version: VersionSpec | None = None
) -> Awaitable[Prompt | None]: ...
class Transform:
@ -60,9 +89,9 @@ class Transform:
tools = await call_next() # Get tools from downstream
return [transform(t) for t in tools] # Transform them
async def get_tool(self, name, call_next):
async def get_tool(self, name, call_next, *, version=None):
original = self.reverse_name(name) # Map to original name
tool = await call_next(original) # Get from downstream
tool = await call_next(original, version=version) # Get from downstream
return transform(tool) if tool else None
```
"""
@ -85,17 +114,20 @@ class Transform:
"""
return await call_next()
async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Get a tool by name.
Args:
name: The requested tool name (may be transformed).
call_next: Callable to get tool from downstream.
version: Optional version filter to apply.
Returns:
The tool if found, None otherwise.
"""
return await call_next(name)
return await call_next(name, version=version)
# -------------------------------------------------------------------------
# Resources
@ -113,18 +145,23 @@ class Transform:
return await call_next()
async def get_resource(
self, uri: str, call_next: GetResourceNext
self,
uri: str,
call_next: GetResourceNext,
*,
version: VersionSpec | None = None,
) -> Resource | None:
"""Get a resource by URI.
Args:
uri: The requested resource URI (may be transformed).
call_next: Callable to get resource from downstream.
version: Optional version filter to apply.
Returns:
The resource if found, None otherwise.
"""
return await call_next(uri)
return await call_next(uri, version=version)
# -------------------------------------------------------------------------
# Resource Templates
@ -144,18 +181,23 @@ class Transform:
return await call_next()
async def get_resource_template(
self, uri: str, call_next: GetResourceTemplateNext
self,
uri: str,
call_next: GetResourceTemplateNext,
*,
version: VersionSpec | None = None,
) -> ResourceTemplate | None:
"""Get a resource template by URI.
Args:
uri: The requested template URI (may be transformed).
call_next: Callable to get template from downstream.
version: Optional version filter to apply.
Returns:
The resource template if found, None otherwise.
"""
return await call_next(uri)
return await call_next(uri, version=version)
# -------------------------------------------------------------------------
# Prompts
@ -172,22 +214,26 @@ class Transform:
"""
return await call_next()
async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None:
async def get_prompt(
self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
) -> Prompt | None:
"""Get a prompt by name.
Args:
name: The requested prompt name (may be transformed).
call_next: Callable to get prompt from downstream.
version: Optional version filter to apply.
Returns:
The prompt if found, None otherwise.
"""
return await call_next(name)
return await call_next(name, version=version)
# Re-export built-in transforms (must be after Transform class to avoid circular imports)
from fastmcp.server.transforms.namespace import Namespace # noqa: E402
from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402
from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402
from fastmcp.server.transforms.visibility import Visibility # noqa: E402
__all__ = [
@ -202,5 +248,7 @@ __all__ = [
"Namespace",
"ToolTransform",
"Transform",
"VersionFilter",
"VersionSpec",
"Visibility",
]

View file

@ -17,6 +17,7 @@ from fastmcp.server.transforms import (
ListToolsNext,
Transform,
)
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@ -104,12 +105,14 @@ class Namespace(Transform):
t.model_copy(update={"name": self._transform_name(t.name)}) for t in tools
]
async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Get tool by namespaced name."""
original = self._reverse_name(name)
if original is None:
return None
tool = await call_next(original)
tool = await call_next(original, version=version)
if tool:
return tool.model_copy(update={"name": name})
return None
@ -127,13 +130,17 @@ class Namespace(Transform):
]
async def get_resource(
self, uri: str, call_next: GetResourceNext
self,
uri: str,
call_next: GetResourceNext,
*,
version: VersionSpec | None = None,
) -> Resource | None:
"""Get resource by namespaced URI."""
original = self._reverse_uri(uri)
if original is None:
return None
resource = await call_next(original)
resource = await call_next(original, version=version)
if resource:
return resource.model_copy(update={"uri": uri})
return None
@ -153,13 +160,17 @@ class Namespace(Transform):
]
async def get_resource_template(
self, uri: str, call_next: GetResourceTemplateNext
self,
uri: str,
call_next: GetResourceTemplateNext,
*,
version: VersionSpec | None = None,
) -> ResourceTemplate | None:
"""Get resource template by namespaced URI."""
original = self._reverse_uri(uri)
if original is None:
return None
template = await call_next(original)
template = await call_next(original, version=version)
if template:
return template.model_copy(
update={"uri_template": self._transform_uri(template.uri_template)}
@ -177,12 +188,14 @@ class Namespace(Transform):
p.model_copy(update={"name": self._transform_name(p.name)}) for p in prompts
]
async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None:
async def get_prompt(
self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
) -> Prompt | None:
"""Get prompt by namespaced name."""
original = self._reverse_name(name)
if original is None:
return None
prompt = await call_next(original)
prompt = await call_next(original, version=version)
if prompt:
return prompt.model_copy(update={"name": name})
return None

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
from fastmcp.server.transforms import GetToolNext, ListToolsNext, Transform
from fastmcp.tools.tool_transform import ToolTransformConfig
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.tools.tool import Tool
@ -72,13 +73,15 @@ class ToolTransform(Transform):
result.append(tool)
return result
async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Get tool by transformed name."""
# Check if this name is a transformed name
original_name = self._name_reverse.get(name, name)
# Get the original tool
tool = await call_next(original_name)
tool = await call_next(original_name, version=version)
if tool is None:
return None

View file

@ -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))

View file

@ -23,6 +23,7 @@ from fastmcp.server.transforms import (
ListToolsNext,
Transform,
)
from fastmcp.utilities.versions import VersionSpec
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
@ -60,7 +61,7 @@ class Visibility(Transform):
Example:
```python
visibility = Visibility()
visibility.disable(keys=["tool:secret"])
visibility.disable(keys=["tool:secret@"])
# Now visibility filters out the "secret" tool
```
"""
@ -125,7 +126,7 @@ class Visibility(Transform):
"""Add to blocklist (hide components).
Args:
keys: Component keys to hide (e.g., "tool:my_tool", "resource:file://x")
keys: Component keys to hide (e.g., "tool:my_tool@", "resource:file://x@")
tags: Tags to hide - any component with these tags will be hidden
"""
notifications: set[type[mcp.types.ServerNotificationType]] = set()
@ -242,9 +243,11 @@ class Visibility(Transform):
tools = await call_next()
return [t for t in tools if self.is_enabled(t)]
async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None:
async def get_tool(
self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None
) -> Tool | None:
"""Get tool if enabled, None otherwise."""
tool = await call_next(name)
tool = await call_next(name, version=version)
if tool is None or not self.is_enabled(tool):
return None
return tool
@ -259,10 +262,14 @@ class Visibility(Transform):
return [r for r in resources if self.is_enabled(r)]
async def get_resource(
self, uri: str, call_next: GetResourceNext
self,
uri: str,
call_next: GetResourceNext,
*,
version: VersionSpec | None = None,
) -> Resource | None:
"""Get resource if enabled, None otherwise."""
resource = await call_next(uri)
resource = await call_next(uri, version=version)
if resource is None or not self.is_enabled(resource):
return None
return resource
@ -279,10 +286,14 @@ class Visibility(Transform):
return [t for t in templates if self.is_enabled(t)]
async def get_resource_template(
self, uri: str, call_next: GetResourceTemplateNext
self,
uri: str,
call_next: GetResourceTemplateNext,
*,
version: VersionSpec | None = None,
) -> ResourceTemplate | None:
"""Get resource template if enabled, None otherwise."""
template = await call_next(uri)
template = await call_next(uri, version=version)
if template is None or not self.is_enabled(template):
return None
return template
@ -296,9 +307,11 @@ class Visibility(Transform):
prompts = await call_next()
return [p for p in prompts if self.is_enabled(p)]
async def get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None:
async def get_prompt(
self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None
) -> Prompt | None:
"""Get prompt if enabled, None otherwise."""
prompt = await call_next(name)
prompt = await call_next(name, version=version)
if prompt is None or not self.is_enabled(prompt):
return None
return prompt

View file

@ -64,6 +64,7 @@ class ToolMeta:
type: Literal["tool"] = field(default="tool", init=False)
name: str | None = None
version: str | int | None = None
title: str | None = None
description: str | None = None
icons: list[Icon] | None = None
@ -111,6 +112,7 @@ class FunctionTool(Tool):
metadata: ToolMeta | None = None,
# Keep individual params for backwards compat
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -139,6 +141,7 @@ class FunctionTool(Tool):
x is not None and x is not NotSet
for x in [
name,
version,
title,
description,
icons,
@ -165,6 +168,7 @@ class FunctionTool(Tool):
if metadata is None:
metadata = ToolMeta(
name=name,
version=version,
title=title,
description=description,
icons=icons,
@ -228,6 +232,7 @@ class FunctionTool(Tool):
return cls(
fn=parsed_fn.fn,
name=metadata.name or parsed_fn.name,
version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
description=metadata.description or parsed_fn.description,
icons=metadata.icons,
@ -329,6 +334,7 @@ def tool(fn: F) -> F: ...
def tool(
name_or_fn: str,
*,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -347,6 +353,7 @@ def tool(
name_or_fn: None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -366,6 +373,7 @@ def tool(
name_or_fn: str | Callable[..., Any] | None = None,
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -397,6 +405,7 @@ def tool(
# Create metadata first, then pass it
tool_meta = ToolMeta(
name=tool_name,
version=version,
title=title,
description=description,
icons=icons,
@ -415,6 +424,7 @@ def tool(
def attach_metadata(fn: F, tool_name: str | None) -> F:
metadata = ToolMeta(
name=tool_name,
version=version,
title=title,
description=description,
icons=icons,

View file

@ -196,6 +196,7 @@ class Tool(FastMCPComponent):
fn: Callable[..., Any],
*,
name: str | None = None,
version: str | int | None = None,
title: str | None = None,
description: str | None = None,
icons: list[Icon] | None = None,
@ -215,6 +216,7 @@ class Tool(FastMCPComponent):
return FunctionTool.from_function(
fn=fn,
name=name,
version=version,
title=title,
description=description,
icons=icons,

View file

@ -367,6 +367,7 @@ class TransformedTool(Tool):
cls,
tool: Tool,
name: str | None = None,
version: str | NotSetT | None = NotSet,
title: str | NotSetT | None = NotSet,
description: str | NotSetT | None = NotSet,
tags: set[str] | None = None,
@ -385,6 +386,7 @@ class TransformedTool(Tool):
to call the parent tool. Functions with **kwargs receive transformed
argument names.
name: New name for the tool. Defaults to parent tool's name.
version: New version for the tool. Defaults to parent tool's version.
title: New title for the tool. Defaults to parent tool's title.
transform_args: Optional transformations for parent tool arguments.
Only specified arguments are transformed, others pass through unchanged:
@ -569,6 +571,7 @@ class TransformedTool(Tool):
)
final_name = name or tool.name
final_version = version if not isinstance(version, NotSetT) else tool.version
final_description = (
description if not isinstance(description, NotSetT) else tool.description
)
@ -586,6 +589,7 @@ class TransformedTool(Tool):
forwarding_fn=forwarding_fn,
parent_tool=tool,
name=final_name,
version=final_version,
title=final_title,
description=final_description,
parameters=final_schema,
@ -892,7 +896,9 @@ class ToolTransformConfig(FastMCPBaseModel):
"""Provides a way to transform a tool."""
name: str | None = Field(default=None, description="The new name for the tool.")
version: str | None = Field(
default=None, description="The new version for the tool."
)
title: str | None = Field(
default=None,
description="The new title of the tool.",

View file

@ -31,6 +31,21 @@ def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]:
return set(maybe_set)
def _coerce_version(v: str | int | None) -> str | None:
"""Coerce version to string, accepting int or str.
Raises ValueError if version contains '@' (used as key delimiter).
"""
if v is None:
return None
version = str(v)
if "@" in version:
raise ValueError(
f"Version string cannot contain '@' (used as key delimiter): {version!r}"
)
return version
class FastMCPComponent(FastMCPBaseModel):
"""Base class for FastMCP tools, prompts, resources, and resource templates."""
@ -52,6 +67,11 @@ class FastMCPComponent(FastMCPBaseModel):
name: str = Field(
description="The name of the component.",
)
version: Annotated[str | None, BeforeValidator(_coerce_version)] = Field(
default=None,
description="Optional version identifier for this component. "
"Multiple versions of the same component (same name) can coexist.",
)
title: str | None = Field(
default=None,
description="The title of the component for display purposes.",
@ -94,12 +114,17 @@ class FastMCPComponent(FastMCPBaseModel):
def key(self) -> str:
"""The globally unique lookup key for this component.
Format: "{key_prefix}:{identifier}" e.g. "tool:my_tool", "resource:file://x.txt"
Format: "{key_prefix}:{identifier}@{version}" or "{key_prefix}:{identifier}@"
e.g. "tool:my_tool@v2", "tool:my_tool@", "resource:file://x.txt@"
The @ suffix is ALWAYS present to enable unambiguous parsing of keys
(URIs may contain @ characters, so we always include the delimiter).
Subclasses should override this to use their specific identifier.
Base implementation uses name.
"""
return self.make_key(self.name)
base_key = self.make_key(self.name)
return f"{base_key}@{self.version or ''}"
def get_meta(
self, include_fastmcp_meta: bool | None = None
@ -133,7 +158,17 @@ class FastMCPComponent(FastMCPBaseModel):
return self.model_dump() == other.model_dump()
def __repr__(self) -> str:
return f"{self.__class__.__name__}(name={self.name!r}, title={self.title!r}, description={self.description!r}, tags={self.tags})"
parts = [f"name={self.name!r}"]
if self.version:
parts.append(f"version={self.version!r}")
parts.extend(
[
f"title={self.title!r}",
f"description={self.description!r}",
f"tags={self.tags}",
]
)
return f"{self.__class__.__name__}({', '.join(parts)})"
def enable(self) -> None:
"""Removed in 3.0. Use server.enable(keys=[...]) instead."""

View file

@ -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

View file

@ -91,7 +91,7 @@ def notification_test_server(recording_message_handler):
"""Enable the target tool."""
# Find and enable the target tool
try:
ctx.fastmcp.enable(keys=["tool:target_tool"])
ctx.fastmcp.enable(keys=["tool:target_tool@"])
return "Target tool enabled"
except Exception:
return "Target tool not found"
@ -102,7 +102,7 @@ def notification_test_server(recording_message_handler):
"""Disable the target tool."""
# Find and disable the target tool
try:
ctx.fastmcp.disable(keys=["tool:target_tool"])
ctx.fastmcp.disable(keys=["tool:target_tool@"])
return "Target tool disabled"
except Exception:
return "Target tool not found"
@ -219,7 +219,7 @@ def resource_notification_test_server(recording_message_handler):
def enable_target_resource(ctx: Context) -> str:
"""Enable the target resource."""
try:
ctx.fastmcp.enable(keys=["resource:resource://target"])
ctx.fastmcp.enable(keys=["resource:resource://target@"])
return "Target resource enabled"
except Exception:
return "Target resource not found"
@ -229,7 +229,7 @@ def resource_notification_test_server(recording_message_handler):
def disable_target_resource(ctx: Context) -> str:
"""Disable the target resource."""
try:
ctx.fastmcp.disable(keys=["resource:resource://target"])
ctx.fastmcp.disable(keys=["resource:resource://target@"])
return "Target resource disabled"
except Exception:
return "Target resource not found"
@ -302,7 +302,7 @@ def prompt_notification_test_server(recording_message_handler):
def enable_target_prompt(ctx: Context) -> str:
"""Enable the target prompt."""
try:
ctx.fastmcp.enable(keys=["prompt:target_prompt"])
ctx.fastmcp.enable(keys=["prompt:target_prompt@"])
return "Target prompt enabled"
except Exception:
return "Target prompt not found"
@ -312,7 +312,7 @@ def prompt_notification_test_server(recording_message_handler):
def disable_target_prompt(ctx: Context) -> str:
"""Disable the target prompt."""
try:
ctx.fastmcp.disable(keys=["prompt:target_prompt"])
ctx.fastmcp.disable(keys=["prompt:target_prompt@"])
return "Target prompt disabled"
except Exception:
return "Target prompt not found"

View file

@ -79,7 +79,7 @@ class TestComponentManagementRoutes:
async def test_enable_tool_route(self, client, mcp):
"""Test enabling a tool via the HTTP route."""
# First disable the tool
mcp.disable(keys=["tool:test_tool"])
mcp.disable(keys=["tool:test_tool@"])
tools = await mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -112,7 +112,7 @@ class TestComponentManagementRoutes:
async def test_enable_resource_route(self, client, mcp):
"""Test enabling a resource via the HTTP route."""
# First disable the resource
mcp.disable(keys=["resource:data://test_resource"])
mcp.disable(keys=["resource:data://test_resource@"])
resources = await mcp.get_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@ -145,7 +145,7 @@ class TestComponentManagementRoutes:
async def test_enable_template_route(self, client, mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://test_resource/{id}"
mcp.disable(keys=["template:data://test_resource/{id}"])
mcp.disable(keys=["template:data://test_resource/{id}@"])
templates = await mcp.get_resource_templates()
assert not any(t.uri_template == key for t in templates)
response = client.post("/resources/data://test_resource/{id}/enable")
@ -172,7 +172,7 @@ class TestComponentManagementRoutes:
async def test_enable_prompt_route(self, client, mcp):
"""Test enabling a prompt via the HTTP route."""
# First disable the prompt
mcp.disable(keys=["prompt:test_prompt"])
mcp.disable(keys=["prompt:test_prompt@"])
prompts = await mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@ -205,7 +205,7 @@ class TestComponentManagementRoutes:
async def test_enable_tool_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a tool on a mounted server via the parent server's HTTP route."""
# Disable the tool on the sub-server
mounted_mcp.disable(keys=["tool:mounted_tool"])
mounted_mcp.disable(keys=["tool:mounted_tool@"])
tools = await mounted_mcp.get_tools()
assert not any(t.name == "mounted_tool" for t in tools)
# Enable via parent
@ -231,7 +231,7 @@ class TestComponentManagementRoutes:
async def test_enable_resource_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
mounted_mcp.disable(keys=["resource:data://mounted_resource"])
mounted_mcp.disable(keys=["resource:data://mounted_resource@"])
resources = await mounted_mcp.get_resources()
assert not any(str(r.uri) == "data://mounted_resource" for r in resources)
response = client.post("/resources/data://sub/mounted_resource/enable")
@ -257,7 +257,7 @@ class TestComponentManagementRoutes:
async def test_enable_template_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a resource on a mounted server via the parent server's HTTP route."""
key = "data://mounted_resource/{id}"
mounted_mcp.disable(keys=["template:data://mounted_resource/{id}"])
mounted_mcp.disable(keys=["template:data://mounted_resource/{id}@"])
templates = await mounted_mcp.get_resource_templates()
assert not any(t.uri_template == key for t in templates)
response = client.post("/resources/data://sub/mounted_resource/{id}/enable")
@ -283,7 +283,7 @@ class TestComponentManagementRoutes:
async def test_enable_prompt_route_on_mounted_server(self, client, mounted_mcp):
"""Test enabling a prompt on a mounted server via the parent server's HTTP route."""
mounted_mcp.disable(keys=["prompt:mounted_prompt"])
mounted_mcp.disable(keys=["prompt:mounted_prompt@"])
prompts = await mounted_mcp.get_prompts()
assert not any(p.name == "mounted_prompt" for p in prompts)
response = client.post("/prompts/sub_mounted_prompt/enable")
@ -389,7 +389,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_enable_tool(self):
"""Test that unauthenticated requests to enable a tool are rejected."""
self.mcp.disable(keys=["tool:test_tool"])
self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -400,7 +400,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_tool(self):
"""Test that authenticated requests to enable a tool are allowed."""
self.mcp.disable(keys=["tool:test_tool"])
self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -438,7 +438,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_tool(self):
"""Test that requests with insufficient scopes are rejected."""
self.mcp.disable(keys=["tool:test_tool"])
self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
@ -452,7 +452,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_resource(self):
"""Test that authenticated requests to enable a resource are allowed."""
self.mcp.disable(keys=["resource:data://test_resource"])
self.mcp.disable(keys=["resource:data://test_resource@"])
resources = await self.mcp.get_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@ -477,7 +477,7 @@ class TestAuthComponentManagementRoutes:
async def test_forbidden_enable_resource(self):
"""Test that requests with insufficient scopes are rejected."""
self.mcp.disable(keys=["resource:data://test_resource"])
self.mcp.disable(keys=["resource:data://test_resource@"])
resources = await self.mcp.get_resources()
assert not any(str(r.uri) == "data://test_resource" for r in resources)
@ -505,7 +505,7 @@ class TestAuthComponentManagementRoutes:
async def test_unauthorized_enable_prompt(self):
"""Test that unauthenticated requests to enable a prompt are rejected."""
self.mcp.disable(keys=["prompt:test_prompt"])
self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@ -516,7 +516,7 @@ class TestAuthComponentManagementRoutes:
async def test_authorized_enable_prompt(self):
"""Test that authenticated requests to enable a prompt are allowed."""
self.mcp.disable(keys=["prompt:test_prompt"])
self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
@ -594,7 +594,7 @@ class TestComponentManagerWithPath:
return TestClient(mcp_with_path.http_app())
async def test_enable_tool_route_with_path(self, client_with_path, mcp_with_path):
mcp_with_path.disable(keys=["tool:test_tool"])
mcp_with_path.disable(keys=["tool:test_tool@"])
tools = await mcp_with_path.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = client_with_path.post("/test/tools/test_tool/enable")
@ -615,7 +615,7 @@ class TestComponentManagerWithPath:
assert not any(str(r.uri) == "data://test_resource" for r in resources)
async def test_enable_prompt_route_with_path(self, client_with_path, mcp_with_path):
mcp_with_path.disable(keys=["prompt:test_prompt"])
mcp_with_path.disable(keys=["prompt:test_prompt@"])
prompts = await mcp_with_path.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = client_with_path.post("/test/prompts/test_prompt/enable")
@ -668,7 +668,7 @@ class TestComponentManagerWithPathAuth:
self.client = TestClient(self.mcp.http_app())
async def test_unauthorized_enable_tool(self):
self.mcp.disable(keys=["tool:test_tool"])
self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post("/test/tools/test_tool/enable")
@ -677,7 +677,7 @@ class TestComponentManagerWithPathAuth:
assert not any(t.name == "test_tool" for t in tools)
async def test_forbidden_enable_tool(self):
self.mcp.disable(keys=["tool:test_tool"])
self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post(
@ -689,7 +689,7 @@ class TestComponentManagerWithPathAuth:
assert not any(t.name == "test_tool" for t in tools)
async def test_authorized_enable_tool(self):
self.mcp.disable(keys=["tool:test_tool"])
self.mcp.disable(keys=["tool:test_tool@"])
tools = await self.mcp.get_tools()
assert not any(t.name == "test_tool" for t in tools)
response = self.client.post(
@ -733,7 +733,7 @@ class TestComponentManagerWithPathAuth:
assert not any(str(r.uri) == "data://test_resource" for r in resources)
async def test_unauthorized_enable_prompt(self):
self.mcp.disable(keys=["prompt:test_prompt"])
self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post("/test/prompts/test_prompt/enable")
@ -742,7 +742,7 @@ class TestComponentManagerWithPathAuth:
assert not any(p.name == "test_prompt" for p in prompts)
async def test_forbidden_enable_prompt(self):
self.mcp.disable(keys=["prompt:test_prompt"])
self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post(
@ -754,7 +754,7 @@ class TestComponentManagerWithPathAuth:
assert not any(p.name == "test_prompt" for p in prompts)
async def test_authorized_enable_prompt(self):
self.mcp.disable(keys=["prompt:test_prompt"])
self.mcp.disable(keys=["prompt:test_prompt@"])
prompts = await self.mcp.get_prompts()
assert not any(p.name == "test_prompt" for p in prompts)
response = self.client.post(

View file

@ -332,7 +332,7 @@ class TestLoggingMiddleware:
assert get_log_lines(caplog) == snapshot(
[
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"title\\":null,\\"description\\":null,\\"icons\\":null,\\"tags\\":[],\\"meta\\":null,\\"task_config\\":{\\"mode\\":\\"forbidden\\",\\"poll_interval\\":\\"PT5S\\"},\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}',
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"version\\":null,\\"title\\":null,\\"description\\":null,\\"icons\\":null,\\"tags\\":[],\\"meta\\":null,\\"task_config\\":{\\"mode\\":\\"forbidden\\",\\"poll_interval\\":\\"PT5S\\"},\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}',
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
]
)

View file

@ -35,8 +35,8 @@ class TestLocalProviderStorage:
)
provider.add_tool(tool)
assert "tool:test_tool" in provider._components
assert provider._components["tool:test_tool"] is tool
assert "tool:test_tool@" in provider._components
assert provider._components["tool:test_tool@"] is tool
def test_add_multiple_tools(self):
"""Test adding multiple tools."""
@ -55,8 +55,8 @@ class TestLocalProviderStorage:
provider.add_tool(tool1)
provider.add_tool(tool2)
assert "tool:tool1" in provider._components
assert "tool:tool2" in provider._components
assert "tool:tool1@" in provider._components
assert "tool:tool2@" in provider._components
def test_remove_tool(self):
"""Test removing a tool from LocalProvider."""
@ -70,7 +70,7 @@ class TestLocalProviderStorage:
provider.add_tool(tool)
provider.remove_tool("test_tool")
assert "tool:test_tool" not in provider._components
assert "tool:test_tool@" not in provider._components
def test_remove_nonexistent_tool_raises(self):
"""Test that removing a nonexistent tool raises KeyError."""
@ -87,7 +87,7 @@ class TestLocalProviderStorage:
def test_resource() -> str:
return "content"
assert "resource:resource://test" in provider._components
assert "resource:resource://test@" in provider._components
def test_remove_resource(self):
"""Test removing a resource from LocalProvider."""
@ -99,7 +99,7 @@ class TestLocalProviderStorage:
provider.remove_resource("resource://test")
assert "resource:resource://test" not in provider._components
assert "resource:resource://test@" not in provider._components
def test_add_template(self):
"""Test adding a resource template to LocalProvider."""
@ -109,7 +109,7 @@ class TestLocalProviderStorage:
def template_fn(id: str) -> str:
return f"Resource {id}"
assert "template:resource://{id}" in provider._components
assert "template:resource://{id}@" in provider._components
def test_remove_template(self):
"""Test removing a resource template from LocalProvider."""
@ -121,7 +121,7 @@ class TestLocalProviderStorage:
provider.remove_template("resource://{id}")
assert "template:resource://{id}" not in provider._components
assert "template:resource://{id}@" not in provider._components
def test_add_prompt(self):
"""Test adding a prompt to LocalProvider."""
@ -133,7 +133,7 @@ class TestLocalProviderStorage:
)
provider.add_prompt(prompt)
assert "prompt:test_prompt" in provider._components
assert "prompt:test_prompt@" in provider._components
def test_remove_prompt(self):
"""Test removing a prompt from LocalProvider."""
@ -146,7 +146,7 @@ class TestLocalProviderStorage:
provider.add_prompt(prompt)
provider.remove_prompt("test_prompt")
assert "prompt:test_prompt" not in provider._components
assert "prompt:test_prompt@" not in provider._components
class TestLocalProviderInterface:
@ -310,8 +310,8 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
assert "tool:my_tool" in provider._components
assert provider._components["tool:my_tool"].name == "my_tool"
assert "tool:my_tool@" in provider._components
assert provider._components["tool:my_tool@"].name == "my_tool"
def test_tool_decorator_with_custom_name_registers(self):
"""Tool with custom name should register under that name."""
@ -321,8 +321,8 @@ class TestLocalProviderDecorators:
def my_tool(x: int) -> int:
return x * 2
assert "tool:custom_name" in provider._components
assert "tool:my_tool" not in provider._components
assert "tool:custom_name@" in provider._components
assert "tool:my_tool@" not in provider._components
def test_tool_direct_call(self):
"""provider.tool(fn) should register the function."""
@ -333,7 +333,7 @@ class TestLocalProviderDecorators:
provider.tool(my_tool, name="direct_tool")
assert "tool:direct_tool" in provider._components
assert "tool:direct_tool@" in provider._components
def test_tool_enabled_false(self):
"""Tool with enabled=False should be disabled."""
@ -343,8 +343,8 @@ class TestLocalProviderDecorators:
def disabled_tool() -> str:
return "should be disabled"
assert "tool:disabled_tool" in provider._components
tool = provider._components["tool:disabled_tool"]
assert "tool:disabled_tool@" in provider._components
tool = provider._components["tool:disabled_tool@"]
assert not provider._is_component_enabled(tool)
async def test_tool_enabled_false_not_listed(self):
@ -386,7 +386,7 @@ class TestLocalProviderDecorators:
def my_resource() -> str:
return "test content"
assert "resource:resource://test" in provider._components
assert "resource:resource://test@" in provider._components
def test_resource_with_custom_name_registers(self):
"""Resource with custom name should register with that name."""
@ -396,7 +396,7 @@ class TestLocalProviderDecorators:
def my_resource() -> str:
return "test content"
assert provider._components["resource:resource://test"].name == "custom_name"
assert provider._components["resource:resource://test@"].name == "custom_name"
def test_resource_enabled_false(self):
"""Resource with enabled=False should be disabled."""
@ -406,8 +406,8 @@ class TestLocalProviderDecorators:
def disabled_resource() -> str:
return "should be disabled"
assert "resource:resource://test" in provider._components
resource = provider._components["resource:resource://test"]
assert "resource:resource://test@" in provider._components
resource = provider._components["resource:resource://test@"]
assert not provider._is_component_enabled(resource)
async def test_resource_enabled_false_not_listed(self):
@ -435,8 +435,8 @@ class TestLocalProviderDecorators:
def disabled_template(id: str) -> str:
return f"Data {id}"
assert "template:data://{id}" in provider._components
template = provider._components["template:data://{id}"]
assert "template:data://{id}@" in provider._components
template = provider._components["template:data://{id}@"]
assert not provider._is_component_enabled(template)
async def test_template_enabled_false_not_listed(self):
@ -478,7 +478,7 @@ class TestLocalProviderDecorators:
def my_prompt() -> str:
return "A prompt"
assert "prompt:my_prompt" in provider._components
assert "prompt:my_prompt@" in provider._components
def test_prompt_with_custom_name_registers(self):
"""Prompt with custom name should register under that name."""
@ -488,8 +488,8 @@ class TestLocalProviderDecorators:
def my_prompt() -> str:
return "A prompt"
assert "prompt:custom_prompt" in provider._components
assert "prompt:my_prompt" not in provider._components
assert "prompt:custom_prompt@" in provider._components
assert "prompt:my_prompt@" not in provider._components
def test_prompt_enabled_false(self):
"""Prompt with enabled=False should be disabled."""
@ -499,8 +499,8 @@ class TestLocalProviderDecorators:
def disabled_prompt() -> str:
return "should be disabled"
assert "prompt:disabled_prompt" in provider._components
prompt = provider._components["prompt:disabled_prompt"]
assert "prompt:disabled_prompt@" in provider._components
prompt = provider._components["prompt:disabled_prompt@"]
assert not provider._is_component_enabled(prompt)
async def test_prompt_enabled_false_not_listed(self):
@ -577,8 +577,8 @@ class TestProviderToolTransformations:
)
# Get tool through layer with call_next
async def get_tool(name: str):
return await provider.get_tool(name)
async def get_tool(name: str, version=None):
return await provider.get_tool(name, version)
tool = await layer.get_tool("transformed_tool", get_tool)
assert tool is not None
@ -603,8 +603,8 @@ class TestProviderToolTransformations:
{"my_tool": ToolTransformConfig(description="New description")}
)
async def get_tool(name: str):
return await provider.get_tool(name)
async def get_tool(name: str, version=None):
return await provider.get_tool(name, version)
tool = await layer.get_tool("my_tool", get_tool)
assert tool is not None

View file

@ -331,12 +331,12 @@ class TestPromptEnabled:
prompts = await mcp.get_prompts()
assert any(p.name == "sample_prompt" for p in prompts)
mcp.disable(keys=["prompt:sample_prompt"])
mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert not any(p.name == "sample_prompt" for p in prompts)
mcp.enable(keys=["prompt:sample_prompt"])
mcp.enable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert any(p.name == "sample_prompt" for p in prompts)
@ -348,7 +348,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
mcp.disable(keys=["prompt:sample_prompt"])
mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 0
@ -362,11 +362,11 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
mcp.disable(keys=["prompt:sample_prompt"])
mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert not any(p.name == "sample_prompt" for p in prompts)
mcp.enable(keys=["prompt:sample_prompt"])
mcp.enable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 1
@ -377,7 +377,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
mcp.disable(keys=["prompt:sample_prompt"])
mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 0
@ -394,7 +394,7 @@ class TestPromptEnabled:
prompt = await mcp.get_prompt("sample_prompt")
assert prompt is not None
mcp.disable(keys=["prompt:sample_prompt"])
mcp.disable(keys=["prompt:sample_prompt@"])
prompts = await mcp.get_prompts()
assert len(prompts) == 0
@ -408,7 +408,7 @@ class TestPromptEnabled:
def sample_prompt() -> str:
return "Hello, world!"
mcp.disable(keys=["prompt:sample_prompt"])
mcp.disable(keys=["prompt:sample_prompt@"])
with pytest.raises(NotFoundError, match="Unknown prompt"):
await mcp.get_prompt("sample_prompt")

View file

@ -738,12 +738,12 @@ class TestResourceEnabled:
resources = await mcp.get_resources()
assert any(str(r.uri) == "resource://data" for r in resources)
mcp.disable(keys=["resource:resource://data"])
mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert not any(str(r.uri) == "resource://data" for r in resources)
mcp.enable(keys=["resource:resource://data"])
mcp.enable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert any(str(r.uri) == "resource://data" for r in resources)
@ -755,7 +755,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
mcp.disable(keys=["resource:resource://data"])
mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 0
@ -769,11 +769,11 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
mcp.disable(keys=["resource:resource://data"])
mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert not any(str(r.uri) == "resource://data" for r in resources)
mcp.enable(keys=["resource:resource://data"])
mcp.enable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 1
@ -784,7 +784,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
mcp.disable(keys=["resource:resource://data"])
mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 0
@ -801,7 +801,7 @@ class TestResourceEnabled:
resource = await mcp.get_resource("resource://data")
assert resource is not None
mcp.disable(keys=["resource:resource://data"])
mcp.disable(keys=["resource:resource://data@"])
resources = await mcp.get_resources()
assert len(resources) == 0
@ -815,7 +815,7 @@ class TestResourceEnabled:
def sample_resource() -> str:
return "Hello, world!"
mcp.disable(keys=["resource:resource://data"])
mcp.disable(keys=["resource:resource://data@"])
with pytest.raises(NotFoundError, match="Unknown resource"):
await mcp.read_resource("resource://data")
@ -891,12 +891,12 @@ class TestResourceTemplateEnabled:
templates = await mcp.get_resource_templates()
assert any(t.uri_template == "resource://{param}" for t in templates)
mcp.disable(keys=["template:resource://{param}"])
mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert not any(t.uri_template == "resource://{param}" for t in templates)
mcp.enable(keys=["template:resource://{param}"])
mcp.enable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert any(t.uri_template == "resource://{param}" for t in templates)
@ -908,7 +908,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
mcp.disable(keys=["template:resource://{param}"])
mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 0
@ -922,11 +922,11 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
mcp.disable(keys=["template:resource://{param}"])
mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert not any(t.uri_template == "resource://{param}" for t in templates)
mcp.enable(keys=["template:resource://{param}"])
mcp.enable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 1
@ -937,7 +937,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
mcp.disable(keys=["template:resource://{param}"])
mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 0
@ -954,7 +954,7 @@ class TestResourceTemplateEnabled:
template = await mcp.get_resource_template("resource://{param}")
assert template is not None
mcp.disable(keys=["template:resource://{param}"])
mcp.disable(keys=["template:resource://{param}@"])
templates = await mcp.get_resource_templates()
assert len(templates) == 0
@ -968,7 +968,7 @@ class TestResourceTemplateEnabled:
def sample_template(param: str) -> str:
return f"Template: {param}"
mcp.disable(keys=["template:resource://{param}"])
mcp.disable(keys=["template:resource://{param}@"])
with pytest.raises(NotFoundError, match="Unknown resource"):
await mcp.read_resource("resource://test")

View file

@ -1473,14 +1473,14 @@ class TestToolEnabled:
assert any(t.name == "sample_tool" for t in tools)
# Disable via server
mcp.disable(keys=["tool:sample_tool"])
mcp.disable(keys=["tool:sample_tool@"])
# Tool should not be in list when disabled
tools = await mcp.get_tools()
assert not any(t.name == "sample_tool" for t in tools)
# Re-enable via server
mcp.enable(keys=["tool:sample_tool"])
mcp.enable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert any(t.name == "sample_tool" for t in tools)
@ -1491,7 +1491,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
mcp.disable(keys=["tool:sample_tool"])
mcp.disable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 0
@ -1505,8 +1505,8 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
mcp.disable(keys=["tool:sample_tool"])
mcp.enable(keys=["tool:sample_tool"])
mcp.disable(keys=["tool:sample_tool@"])
mcp.enable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 1
@ -1517,7 +1517,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
mcp.disable(keys=["tool:sample_tool"])
mcp.disable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 0
@ -1534,7 +1534,7 @@ class TestToolEnabled:
tool = await mcp.get_tool(name="sample_tool")
assert tool is not None
mcp.disable(keys=["tool:sample_tool"])
mcp.disable(keys=["tool:sample_tool@"])
tools = await mcp.get_tools()
assert len(tools) == 0
@ -1548,7 +1548,7 @@ class TestToolEnabled:
def sample_tool(x: int) -> int:
return x * 2
mcp.disable(keys=["tool:sample_tool"])
mcp.disable(keys=["tool:sample_tool@"])
with pytest.raises(NotFoundError, match="Unknown tool"):
await mcp.call_tool("sample_tool", {"x": 5})

View file

@ -158,8 +158,8 @@ class TestTransformReverseLookup:
layer = Namespace("ns")
# Create call_next that delegates to provider
async def get_tool(name: str):
return await provider.get_tool(name)
async def get_tool(name: str, version=None):
return await provider.get_tool(name, version)
tool = await layer.get_tool("ns_my_tool", get_tool)
@ -177,8 +177,8 @@ class TestTransformReverseLookup:
provider = FastMCPProvider(server)
layer = ToolTransform({"original": ToolTransformConfig(name="renamed")})
async def get_tool(name: str):
return await provider.get_tool(name)
async def get_tool(name: str, version=None):
return await provider.get_tool(name, version)
tool = await layer.get_tool("renamed", get_tool)
@ -196,8 +196,8 @@ class TestTransformReverseLookup:
provider = FastMCPProvider(server)
layer = Namespace("ns")
async def get_resource(uri: str):
return await provider.get_resource(uri)
async def get_resource(uri: str, version=None):
return await provider.get_resource(uri, version)
resource = await layer.get_resource("resource://ns/data", get_resource)
@ -215,8 +215,8 @@ class TestTransformReverseLookup:
provider = FastMCPProvider(server)
layer = Namespace("ns")
async def get_tool(name: str):
return await provider.get_tool(name)
async def get_tool(name: str, version=None):
return await provider.get_tool(name, version)
# Wrong namespace prefix
assert await layer.get_tool("wrong_my_tool", get_tool) is None

View file

@ -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():

View file

@ -31,9 +31,9 @@ async def test_server_tasks_true_defaults_all_components():
# Components use prefixed keys: tool:name, prompt:name, resource:uri
docket = mcp.docket
assert docket is not None
assert "tool:my_tool" in docket.tasks
assert "prompt:my_prompt" in docket.tasks
assert "resource:test://resource" in docket.tasks
assert "tool:my_tool@" in docket.tasks
assert "prompt:my_prompt@" in docket.tasks
assert "resource:test://resource@" in docket.tasks
# Tool should support background execution
tool_task = await client.call_tool("my_tool", task=True)
@ -118,9 +118,9 @@ async def test_component_explicit_false_overrides_server_true():
docket = mcp.docket
assert docket is not None
assert (
"tool:no_task_tool" not in docket.tasks
"tool:no_task_tool@" not in docket.tasks
) # task=False means not registered
assert "tool:default_tool" in docket.tasks # Inherits tasks=True
assert "tool:default_tool@" in docket.tasks # Inherits tasks=True
# Explicit False (mode="forbidden") returns error when called with task=True
no_task = await client.call_tool("no_task_tool", task=True)
@ -150,8 +150,8 @@ async def test_component_explicit_true_overrides_server_false():
# Verify docket registration matches task settings (prefixed keys)
docket = mcp.docket
assert docket is not None
assert "tool:task_tool" in docket.tasks # task=True means registered
assert "tool:default_tool" not in docket.tasks # Inherits tasks=False
assert "tool:task_tool@" in docket.tasks # task=True means registered
assert "tool:default_tool@" not in docket.tasks # Inherits tasks=False
# Explicit True should support background execution despite server default
task = await client.call_tool("task_tool", task=True)
@ -205,14 +205,14 @@ async def test_mixed_explicit_and_inherited():
docket = mcp.docket
assert docket is not None
# task=True (explicit or inherited) means registered (with prefixed keys)
assert "tool:inherited_tool" in docket.tasks
assert "tool:explicit_true_tool" in docket.tasks
assert "prompt:inherited_prompt" in docket.tasks
assert "resource:test://inherited" in docket.tasks
assert "tool:inherited_tool@" in docket.tasks
assert "tool:explicit_true_tool@" in docket.tasks
assert "prompt:inherited_prompt@" in docket.tasks
assert "resource:test://inherited@" in docket.tasks
# task=False means NOT registered
assert "tool:explicit_false_tool" not in docket.tasks
assert "prompt:explicit_false_prompt" not in docket.tasks
assert "resource:test://explicit_false" not in docket.tasks
assert "tool:explicit_false_tool@" not in docket.tasks
assert "prompt:explicit_false_prompt@" not in docket.tasks
assert "resource:test://explicit_false@" not in docket.tasks
# Tools
inherited = await client.call_tool("inherited_tool", task=True)
@ -331,7 +331,7 @@ async def test_task_with_custom_tool_name():
# Verify the tool is registered with its custom name in Docket (prefixed key)
docket = mcp.docket
assert docket is not None
assert "tool:custom-tool-name" in docket.tasks
assert "tool:custom-tool-name@" in docket.tasks
# Call the tool as a task using its custom name
task = await client.call_tool("custom-tool-name", task=True)
@ -355,7 +355,7 @@ async def test_task_with_custom_resource_name():
# Verify the resource is registered with its key (prefixed URI) in Docket
docket = mcp.docket
assert docket is not None
assert "resource:test://resource" in docket.tasks
assert "resource:test://resource@" in docket.tasks
# Call the resource as a task
task = await client.read_resource("test://resource", task=True)
@ -379,7 +379,7 @@ async def test_task_with_custom_template_name():
# Verify the template is registered with its key (prefixed uri_template) in Docket
docket = mcp.docket
assert docket is not None
assert "template:test://{item_id}" in docket.tasks
assert "template:test://{item_id}@" in docket.tasks
# Call the template as a task
task = await client.read_resource("test://123", task=True)

View file

@ -40,7 +40,7 @@ class TestToolTracing:
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "tool"
assert span.attributes["fastmcp.component.key"] == "tool:greet"
assert span.attributes["fastmcp.component.key"] == "tool:greet@"
async def test_call_tool_with_error_sets_status(
self, trace_exporter: InMemorySpanExporter
@ -108,7 +108,7 @@ class TestResourceTracing:
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "resource"
assert span.attributes["fastmcp.component.key"] == "resource:config://app"
assert span.attributes["fastmcp.component.key"] == "resource:config://app@"
async def test_read_resource_template_creates_span(
self, trace_exporter: InMemorySpanExporter
@ -138,7 +138,7 @@ class TestResourceTracing:
assert span.attributes["fastmcp.component.type"] == "resource_template"
assert (
span.attributes["fastmcp.component.key"]
== "template:users://{user_id}/profile"
== "template:users://{user_id}/profile@"
)
async def test_read_nonexistent_resource_sets_error(
@ -186,7 +186,7 @@ class TestPromptTracing:
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "prompt"
assert span.attributes["fastmcp.component.key"] == "prompt:greeting"
assert span.attributes["fastmcp.component.key"] == "prompt:greeting@"
async def test_render_nonexistent_prompt_sets_error(
self, trace_exporter: InMemorySpanExporter

View file

@ -14,6 +14,7 @@ from fastmcp.resources.resource import Resource
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.providers import Provider
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.versions import VersionSpec
class SimpleTool(Tool):
@ -51,9 +52,17 @@ class SimpleToolProvider(Provider):
self.list_tools_call_count += 1
return self._tools
async def get_tool(self, name: str) -> Tool | None:
async def get_tool(
self, name: str, version: VersionSpec | None = None
) -> Tool | None:
self.get_tool_call_count += 1
return next((t for t in self._tools if t.name == name), None)
matching = [t for t in self._tools if t.name == name]
if not matching:
return None
if version is None:
return matching[0] # Return first (for testing simplicity)
matching = [t for t in matching if version.matches(t.version)]
return matching[0] if matching else None
class ListOnlyProvider(Provider):

View file

@ -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"

View file

@ -1054,13 +1054,13 @@ class TestEnableDisable:
return x + y
# Get the registered Tool object from the server
add_tool = await mcp._local_provider.get_component("tool:add")
add_tool = await mcp._local_provider.get_tool("add")
assert isinstance(add_tool, Tool)
new_add = Tool.from_tool(add_tool, name="new_add")
mcp.add_tool(new_add)
# Disable original tool, but new_add should still work
mcp.disable(keys=["tool:add"])
mcp.disable(keys=["tool:add@"])
async with Client(mcp) as client:
tools = await client.list_tools()
@ -1081,13 +1081,13 @@ class TestEnableDisable:
return x + y
# Get the registered Tool object from the server
add_tool = await mcp._local_provider.get_component("tool:add")
add_tool = await mcp._local_provider.get_tool("add")
assert isinstance(add_tool, Tool)
new_add = Tool.from_tool(add_tool, name="new_add")
mcp.add_tool(new_add)
# Disable both tools via server
mcp.disable(keys=["tool:add", "tool:new_add"])
mcp.disable(keys=["tool:add@", "tool:new_add@"])
async with Client(mcp) as client:
tools = await client.list_tools()

View file

@ -82,8 +82,9 @@ class TestFastMCPComponent:
assert component.meta == meta
def test_key_property_without_custom_key(self, basic_component):
"""Test that key property returns name when no custom key is set."""
assert basic_component.key == "test_component"
"""Test that key property returns name@version when no custom key is set."""
# Base component has no KEY_PREFIX, so key is just "name@version" (or "name@" for unversioned)
assert basic_component.key == "test_component@"
def test_get_meta_without_fastmcp_meta(self, basic_component):
"""Test get_meta without including fastmcp meta."""
@ -207,14 +208,14 @@ class TestKeyPrefix:
assert Prompt.make_key("my_prompt") == "prompt:my_prompt"
def test_tool_key_property(self):
"""Test that Tool.key returns prefixed key."""
"""Test that Tool.key returns prefixed key with version sentinel."""
tool = Tool(name="greet", description="A greeting tool", parameters={})
assert tool.key == "tool:greet"
assert tool.key == "tool:greet@"
def test_prompt_key_property(self):
"""Test that Prompt.key returns prefixed key."""
"""Test that Prompt.key returns prefixed key with version sentinel."""
prompt = Prompt(name="analyze", description="An analysis prompt")
assert prompt.key == "prompt:analyze"
assert prompt.key == "prompt:analyze@"
def test_warning_for_missing_key_prefix(self):
"""Test that subclassing without KEY_PREFIX emits a warning."""
@ -263,21 +264,21 @@ class TestComponentEnableDisable:
tool = Tool(name="my_tool", description="A tool", parameters={})
with pytest.raises(NotImplementedError) as exc_info:
tool.enable()
assert "tool:my_tool" in str(exc_info.value)
assert "tool:my_tool@" in str(exc_info.value)
def test_tool_disable_raises_not_implemented(self):
"""Test that Tool.disable() raises NotImplementedError."""
tool = Tool(name="my_tool", description="A tool", parameters={})
with pytest.raises(NotImplementedError) as exc_info:
tool.disable()
assert "tool:my_tool" in str(exc_info.value)
assert "tool:my_tool@" in str(exc_info.value)
def test_prompt_enable_raises_not_implemented(self):
"""Test that Prompt.enable() raises NotImplementedError."""
prompt = Prompt(name="my_prompt", description="A prompt")
with pytest.raises(NotImplementedError) as exc_info:
prompt.enable()
assert "prompt:my_prompt" in str(exc_info.value)
assert "prompt:my_prompt@" in str(exc_info.value)
class TestFastMCPMeta:
@ -369,13 +370,15 @@ class TestEdgeCasesAndIntegration:
assert updated_component.title == "New Title" # Updated
assert updated_component.description == "New Description" # Updated
assert updated_component.tags == {"tag1"} # Not in update, unchanged
assert updated_component.key == "new_name" # .key is computed from name
assert (
updated_component.key == "new_name@"
) # .key is computed from name with @ sentinel
# Original should be unchanged
assert component.name == "test"
assert component.title == "Original Title"
assert component.description == "Original Description"
assert component.key == "test" # Uses name as key
assert component.key == "test@" # Uses name as key with @ sentinel
def test_model_copy_deep_parameter(self):
"""Test that model_copy respects the deep parameter."""

View file

@ -17,7 +17,7 @@ class TestVisibilityBasics:
"""Disabling by key hides the component."""
v = Visibility()
tool = Tool(name="test", parameters={})
v.disable(keys=["tool:test"])
v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
def test_disable_by_tag(self):
@ -38,9 +38,9 @@ class TestVisibilityBasics:
"""Enable removes keys/tags from blocklist."""
v = Visibility()
tool = Tool(name="test", parameters={})
v.disable(keys=["tool:test"])
v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
v.enable(keys=["tool:test"])
v.enable(keys=["tool:test@"])
assert v.is_enabled(tool) is True
@ -51,14 +51,14 @@ class TestVisibilityAllowlist:
"""With only=True, non-matching components are hidden."""
v = Visibility()
tool = Tool(name="test", parameters={})
v.enable(keys=["tool:other"], only=True)
v.enable(keys=["tool:other@"], only=True)
assert v.is_enabled(tool) is False
def test_only_mode_shows_matching_key(self):
"""With only=True, matching keys are shown."""
v = Visibility()
tool = Tool(name="test", parameters={})
v.enable(keys=["tool:test"], only=True)
v.enable(keys=["tool:test@"], only=True)
assert v.is_enabled(tool) is True
def test_only_mode_shows_matching_tag(self):
@ -83,8 +83,8 @@ class TestVisibilityPrecedence:
"""Blocklist key beats allowlist key."""
v = Visibility()
tool = Tool(name="test", parameters={})
v.enable(keys=["tool:test"], only=True)
v.disable(keys=["tool:test"])
v.enable(keys=["tool:test@"], only=True)
v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
def test_blocklist_wins_over_allowlist_tag(self):
@ -103,7 +103,7 @@ class TestVisibilityReset:
"""Reset returns to default state."""
v = Visibility()
tool = Tool(name="test", parameters={})
v.disable(keys=["tool:test"])
v.disable(keys=["tool:test@"])
assert v.is_enabled(tool) is False
v.reset()
assert v.is_enabled(tool) is True
@ -112,7 +112,7 @@ class TestVisibilityReset:
"""Reset clears allowlist mode."""
v = Visibility()
tool = Tool(name="test", parameters={})
v.enable(keys=["tool:other"], only=True)
v.enable(keys=["tool:other@"], only=True)
assert v.is_enabled(tool) is False
v.reset()
assert v.is_enabled(tool) is True

2
uv.lock generated
View file

@ -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" },