mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Add component versioning and VersionFilter transform (#2894)
This commit is contained in:
parent
39ad1dab8c
commit
a6cd764b5f
49 changed files with 3143 additions and 529 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
"servers/storage-backends",
|
||||
"servers/tasks",
|
||||
"servers/telemetry",
|
||||
"servers/versioning",
|
||||
"servers/visibility"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
259
docs/servers/versioning.mdx
Normal 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")
|
||||
```
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
132
src/fastmcp/server/transforms/version_filter.py
Normal file
132
src/fastmcp/server/transforms/version_filter.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
282
src/fastmcp/utilities/versions.py
Normal file
282
src/fastmcp/utilities/versions.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
990
tests/server/test_versioning.py
Normal file
990
tests/server/test_versioning.py
Normal 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"
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue