mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add version discovery and calling a certain version for components (#2897)
This commit is contained in:
parent
c64c4a1b3c
commit
33c371cb37
12 changed files with 989 additions and 76 deletions
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
- name: Run tests (excluding integration and client_process)
|
||||
run: |
|
||||
if [ "${{ matrix.os }}" == "windows-latest" ]; then
|
||||
uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" -v
|
||||
uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process"
|
||||
else
|
||||
uv run pytest --inline-snapshot=disable tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -85,12 +85,29 @@ async with client:
|
|||
"name": "Alice",
|
||||
"role": "administrator"
|
||||
})
|
||||
|
||||
|
||||
# Access the personalized messages
|
||||
for message in result.messages:
|
||||
print(f"Generated message: {message.content}")
|
||||
```
|
||||
|
||||
### Requesting Specific Versions
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
When a server has multiple versions of a prompt, you can request a specific version instead of the default (highest) version.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
# Get the highest version (default)
|
||||
result = await client.get_prompt("summarize", {"text": "..."})
|
||||
|
||||
# Get a specific version
|
||||
result_v1 = await client.get_prompt("summarize", {"text": "..."}, version="1.0")
|
||||
```
|
||||
|
||||
To discover available versions, check the `meta.fastmcp.versions` field when listing prompts. See [Version Selection](#version-selection) below for more details.
|
||||
|
||||
## Automatic Argument Serialization
|
||||
|
||||
<VersionBadge version="2.9.0" />
|
||||
|
|
@ -213,4 +230,48 @@ async with client:
|
|||
|
||||
<Tip>
|
||||
Prompt arguments and their expected types depend on the specific prompt implementation. Check the server's documentation or use `list_prompts()` to see available arguments for each prompt.
|
||||
</Tip>
|
||||
</Tip>
|
||||
|
||||
## Version Selection
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
FastMCP servers can expose multiple versions of the same prompt. By default, clients receive and request the highest version, but you can request a specific version when needed.
|
||||
|
||||
### Discovering Versions
|
||||
|
||||
When a server registers multiple versions of a prompt, the `list_prompts()` response includes version information in the metadata. The `meta.fastmcp.version` field shows which version is being returned, while `meta.fastmcp.versions` lists all available versions sorted from highest to lowest.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
prompts = await client.list_prompts()
|
||||
|
||||
for prompt in prompts:
|
||||
if prompt.meta:
|
||||
fastmcp_meta = prompt.meta.get("fastmcp", {})
|
||||
version = fastmcp_meta.get("version")
|
||||
all_versions = fastmcp_meta.get("versions")
|
||||
if all_versions:
|
||||
print(f"{prompt.name}: v{version} (available: {all_versions})")
|
||||
```
|
||||
|
||||
Unversioned prompts omit these metadata fields entirely.
|
||||
|
||||
### Getting Specific Versions
|
||||
|
||||
Pass the `version` parameter to `get_prompt()` to render a specific version instead of the highest.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
# Get the highest version (default)
|
||||
result = await client.get_prompt("summarize", {"text": "..."})
|
||||
|
||||
# Get version 1.0 specifically
|
||||
result_v1 = await client.get_prompt("summarize", {"text": "..."}, version="1.0")
|
||||
```
|
||||
|
||||
If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version.
|
||||
|
||||
<Note>
|
||||
Version selection is a FastMCP extension to the MCP protocol. See [Versioning](/servers/versioning#requesting-specific-versions) for details on how this works at the protocol level for non-FastMCP clients.
|
||||
</Note>
|
||||
|
|
@ -81,9 +81,35 @@ async with client:
|
|||
```
|
||||
|
||||
<Note>
|
||||
The `meta` field is part of the standard MCP specification. FastMCP servers always include tags and other metadata within a `fastmcp` namespace (e.g., `meta.fastmcp.tags`) to avoid conflicts with user-defined metadata. Component versions are also included in the metadata when available (e.g., `meta.fastmcp.version`). Other MCP server implementations may not provide this metadata structure.
|
||||
The `meta` field is part of the standard MCP specification. FastMCP servers always include tags and other metadata within a `fastmcp` namespace (e.g., `meta.fastmcp.tags`) to avoid conflicts with user-defined metadata. For versioned resources, `meta.fastmcp.version` shows the current version and `meta.fastmcp.versions` lists all available versions. Other MCP server implementations may not provide this metadata structure.
|
||||
</Note>
|
||||
|
||||
### Version Information
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
When a server registers multiple versions of a resource, the metadata includes version information.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
resources = await client.list_resources()
|
||||
|
||||
for resource in resources:
|
||||
if resource.meta:
|
||||
fastmcp_meta = resource.meta.get("fastmcp", {})
|
||||
version = fastmcp_meta.get("version")
|
||||
all_versions = fastmcp_meta.get("versions")
|
||||
if all_versions:
|
||||
print(f"{resource.uri}: v{version} (available: {all_versions})")
|
||||
```
|
||||
|
||||
To read a specific version, use the `version` parameter:
|
||||
|
||||
```python
|
||||
# Read a specific version
|
||||
content = await client.read_resource("data://config", version="1.0")
|
||||
```
|
||||
|
||||
## Reading Resources
|
||||
|
||||
### Static Resources
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ async with client:
|
|||
**Parameters:**
|
||||
- `name`: The tool name (string)
|
||||
- `arguments`: Dictionary of arguments to pass to the tool (optional)
|
||||
- `version`: Specific tool version to call (optional, see [Version Selection](#version-selection) below)
|
||||
- `timeout`: Maximum execution time in seconds (optional, overrides client-level timeout)
|
||||
- `progress_handler`: Progress callback function (optional, overrides client-level handler)
|
||||
- `meta`: Dictionary of metadata to send with the request (optional, see below)
|
||||
|
|
@ -292,4 +293,48 @@ async with client:
|
|||
|
||||
<Tip>
|
||||
For multi-server clients, tool names are automatically prefixed with the server name (e.g., `weather_get_forecast` for a tool named `get_forecast` on the `weather` server).
|
||||
</Tip>
|
||||
</Tip>
|
||||
|
||||
## Version Selection
|
||||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
FastMCP servers can expose multiple versions of the same tool. By default, clients receive and call the highest version, but you can request a specific version when needed.
|
||||
|
||||
### Discovering Versions
|
||||
|
||||
When a server registers multiple versions of a tool, the `list_tools()` response includes version information in the metadata. The `meta.fastmcp.version` field shows which version is being returned, while `meta.fastmcp.versions` lists all available versions sorted from highest to lowest.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for tool in tools:
|
||||
if tool.meta:
|
||||
fastmcp_meta = tool.meta.get("fastmcp", {})
|
||||
version = fastmcp_meta.get("version")
|
||||
all_versions = fastmcp_meta.get("versions")
|
||||
if all_versions:
|
||||
print(f"{tool.name}: v{version} (available: {all_versions})")
|
||||
```
|
||||
|
||||
Unversioned tools omit these metadata fields entirely.
|
||||
|
||||
### Calling Specific Versions
|
||||
|
||||
Pass the `version` parameter to `call_tool()` to execute a specific version instead of the highest.
|
||||
|
||||
```python
|
||||
async with client:
|
||||
# Call the highest version (default)
|
||||
result = await client.call_tool("calculate", {"x": 1, "y": 2})
|
||||
|
||||
# Call version 1.0 specifically
|
||||
result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0")
|
||||
```
|
||||
|
||||
If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version.
|
||||
|
||||
<Note>
|
||||
Version selection is a FastMCP extension to the MCP protocol. See [Versioning](/servers/versioning#requesting-specific-versions) for details on how this works at the protocol level for non-FastMCP clients.
|
||||
</Note>
|
||||
|
|
@ -231,7 +231,18 @@ def add(x: int, y: int, z: int = 0) -> int:
|
|||
- Unversioned components sort lower than any versioned component
|
||||
- The `v` prefix is normalized (`v1.0` equals `1.0`)
|
||||
|
||||
**Retrieving specific versions:**
|
||||
**Version visibility in meta:**
|
||||
|
||||
List operations expose all available versions in the component's `meta` field:
|
||||
|
||||
```python
|
||||
tools = await client.list_tools()
|
||||
# Each tool's meta includes:
|
||||
# - meta["fastmcp"]["version"]: the version of this component ("2.0")
|
||||
# - meta["fastmcp"]["versions"]: all available versions ["2.0", "1.0"]
|
||||
```
|
||||
|
||||
**Retrieving and calling specific versions:**
|
||||
|
||||
```python
|
||||
# Get the highest version (default)
|
||||
|
|
@ -240,8 +251,35 @@ tool = await server.get_tool("add")
|
|||
# Get a specific version
|
||||
tool_v1 = await server.get_tool("add", version="1.0")
|
||||
|
||||
# Get all versions
|
||||
all_versions = await server.get_tool_versions("add")
|
||||
# Call a specific version
|
||||
result = await server.call_tool("add", {"x": 1, "y": 2}, version="1.0")
|
||||
```
|
||||
|
||||
**Client version requests:**
|
||||
|
||||
The FastMCP client supports version selection:
|
||||
|
||||
```python
|
||||
async with Client(server) as client:
|
||||
# Call specific tool version
|
||||
result = await client.call_tool("add", {"x": 1, "y": 2}, version="1.0")
|
||||
|
||||
# Get specific prompt version
|
||||
prompt = await client.get_prompt("my_prompt", {"text": "..."}, version="2.0")
|
||||
```
|
||||
|
||||
For generic MCP clients, pass version via `_meta` in arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"_meta": {
|
||||
"fastmcp": {
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**VersionFilter transform:**
|
||||
|
|
|
|||
|
|
@ -157,9 +157,83 @@ 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 Discovery
|
||||
|
||||
When clients list components, each versioned component includes metadata about all available versions. This lets clients discover what versions exist before deciding which to use. The `meta.fastmcp.versions` field contains all registered versions sorted from highest to lowest.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
async with Client(server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
for tool in tools:
|
||||
if tool.meta:
|
||||
fastmcp_meta = tool.meta.get("fastmcp", {})
|
||||
# Current version being returned (highest by default)
|
||||
print(f"Version: {fastmcp_meta.get('version')}")
|
||||
# All available versions for this component
|
||||
print(f"Available: {fastmcp_meta.get('versions')}")
|
||||
```
|
||||
|
||||
For a tool with versions `"1.0"` and `"2.0"`, listing returns the `2.0` implementation with `meta.fastmcp.version` set to `"2.0"` and `meta.fastmcp.versions` set to `["2.0", "1.0"]`. Unversioned components omit these fields entirely.
|
||||
|
||||
This discovery mechanism enables clients to make informed decisions about which version to request, support graceful degradation when newer versions introduce breaking changes, or display version information in developer tools.
|
||||
|
||||
## Requesting Specific Versions
|
||||
|
||||
By default, clients receive and invoke the highest version of each component. When you need a specific version, FastMCP provides two approaches: the FastMCP client API for Python applications, and the MCP protocol mechanism for any MCP-compatible client.
|
||||
|
||||
### FastMCP Client
|
||||
|
||||
The FastMCP client's `call_tool` and `get_prompt` methods accept an optional `version` parameter. When specified, the server executes that exact version instead of the highest.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
|
||||
async with Client(server) as client:
|
||||
# Call the highest version (default behavior)
|
||||
result = await client.call_tool("calculate", {"x": 1, "y": 2})
|
||||
|
||||
# Call a specific version
|
||||
result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0")
|
||||
|
||||
# Get a specific prompt version
|
||||
prompt = await client.get_prompt("summarize", {"text": "..."}, version="1.0")
|
||||
```
|
||||
|
||||
If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version.
|
||||
|
||||
### MCP Protocol
|
||||
|
||||
For generic MCP clients that don't have built-in version support, pass the version through the `_meta` field in arguments. FastMCP servers extract the version from `_meta.fastmcp.version` before processing.
|
||||
|
||||
<CodeGroup>
|
||||
```json Tool Call Arguments
|
||||
{
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
"_meta": {
|
||||
"fastmcp": {
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json Prompt Arguments
|
||||
{
|
||||
"text": "Summarize this document...",
|
||||
"_meta": {
|
||||
"fastmcp": {
|
||||
"version": "1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
The `_meta` field is part of the MCP request params, not arguments, so your component implementation never sees it. This convention allows version selection to work across any MCP client without requiring protocol changes. The FastMCP client handles this automatically when you pass the `version` parameter.
|
||||
|
||||
## Version Comparison
|
||||
|
||||
|
|
|
|||
81
examples/versioning/client_version_selection.py
Normal file
81
examples/versioning/client_version_selection.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""
|
||||
Client-Side Version Selection
|
||||
|
||||
Discover available versions via metadata and request specific versions
|
||||
when calling tools, prompts, or resources.
|
||||
|
||||
Run: uv run python examples/versioning/client_version_selection.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from rich import print
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
mcp = FastMCP("Payment API")
|
||||
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def charge(amount: int, currency: str = "USD") -> dict:
|
||||
"""Charge a payment (v1.0 - basic)."""
|
||||
return {"status": "charged", "amount": amount, "currency": currency}
|
||||
|
||||
|
||||
@mcp.tool(version="1.1")
|
||||
def charge( # noqa: F811
|
||||
amount: int, currency: str = "USD", idempotency_key: str | None = None
|
||||
) -> dict:
|
||||
"""Charge a payment (v1.1 - added idempotency)."""
|
||||
return {"status": "charged", "amount": amount, "idempotency_key": idempotency_key}
|
||||
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def charge( # noqa: F811
|
||||
amount: int,
|
||||
currency: str = "USD",
|
||||
idempotency_key: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
"""Charge a payment (v2.0 - added metadata)."""
|
||||
return {"status": "charged", "amount": amount, "metadata": metadata or {}}
|
||||
|
||||
|
||||
async def main():
|
||||
async with Client(mcp) as client:
|
||||
# Discover versions via metadata
|
||||
tools = await client.list_tools()
|
||||
tool = tools[0]
|
||||
meta = tool.meta.get("fastmcp", {})
|
||||
|
||||
print(f"[bold]{tool.name}[/]")
|
||||
print(f" Current version: [green]{meta.get('version')}[/]")
|
||||
print(f" All versions: {meta.get('versions')}")
|
||||
|
||||
# Call specific versions
|
||||
print("\n[bold]Calling specific versions:[/]")
|
||||
|
||||
r1 = await client.call_tool("charge", {"amount": 100}, version="1.0")
|
||||
print(f" v1.0: {r1.data}")
|
||||
|
||||
r1_1 = await client.call_tool(
|
||||
"charge", {"amount": 100, "idempotency_key": "abc"}, version="1.1"
|
||||
)
|
||||
print(f" v1.1: {r1_1.data}")
|
||||
|
||||
r2 = await client.call_tool(
|
||||
"charge", {"amount": 100, "metadata": {"order": "xyz"}}, version="2.0"
|
||||
)
|
||||
print(f" v2.0: {r2.data}")
|
||||
|
||||
# Handle missing versions
|
||||
print("\n[bold]Missing version:[/]")
|
||||
try:
|
||||
await client.call_tool("charge", {"amount": 100}, version="99.0")
|
||||
except ToolError as e:
|
||||
print(f" [red]ToolError:[/] {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
91
examples/versioning/version_filters.py
Normal file
91
examples/versioning/version_filters.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""
|
||||
Version Filters for API Surfaces
|
||||
|
||||
Use VersionFilter to create distinct API surfaces from shared components.
|
||||
This lets you serve v1, v2, v3 APIs from a single codebase.
|
||||
|
||||
Run: uv run python examples/versioning/version_filters.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from rich import print
|
||||
from rich.table import Table
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.server.providers import LocalProvider
|
||||
from fastmcp.server.transforms import VersionFilter
|
||||
|
||||
# Shared component pool with all versions
|
||||
components = LocalProvider()
|
||||
|
||||
|
||||
@components.tool(version="1.0")
|
||||
def process(data: str) -> str:
|
||||
"""Process data (v1 - uppercase only)."""
|
||||
return data.upper()
|
||||
|
||||
|
||||
@components.tool(version="2.0")
|
||||
def process(data: str, mode: str = "upper") -> str: # noqa: F811
|
||||
"""Process data (v2 - with mode selection)."""
|
||||
return data.lower() if mode == "lower" else data.upper()
|
||||
|
||||
|
||||
@components.tool(version="3.0")
|
||||
def process(data: str, mode: str = "upper", repeat: int = 1) -> str: # noqa: F811
|
||||
"""Process data (v3 - with repeat)."""
|
||||
result = data.lower() if mode == "lower" else data.upper()
|
||||
return result * repeat
|
||||
|
||||
|
||||
# Unversioned components pass through all filters
|
||||
@components.tool()
|
||||
def health() -> str:
|
||||
"""Health check (always available)."""
|
||||
return "ok"
|
||||
|
||||
|
||||
# Create filtered API surfaces
|
||||
api_v1 = FastMCP("API v1", providers=[components])
|
||||
api_v1.add_transform(VersionFilter(version_lt="2.0"))
|
||||
|
||||
api_v2 = FastMCP("API v2", providers=[components])
|
||||
api_v2.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0"))
|
||||
|
||||
api_v3 = FastMCP("API v3", providers=[components])
|
||||
api_v3.add_transform(VersionFilter(version_gte="3.0"))
|
||||
|
||||
|
||||
async def show_surface(name: str, server: FastMCP):
|
||||
"""Show what's visible through a filtered server."""
|
||||
async with Client(server) as client:
|
||||
tools = await client.list_tools()
|
||||
|
||||
table = Table(title=name)
|
||||
table.add_column("Tool")
|
||||
table.add_column("Version", style="green")
|
||||
|
||||
for tool in tools:
|
||||
meta = tool.meta.get("fastmcp", {}) if tool.meta else {}
|
||||
table.add_row(tool.name, meta.get("version", "(unversioned)"))
|
||||
|
||||
print(table)
|
||||
|
||||
|
||||
async def main():
|
||||
# Show what each API surface exposes
|
||||
await show_surface("API v1 (version_lt='2.0')", api_v1)
|
||||
await show_surface("API v2 (version_gte='2.0', version_lt='3.0')", api_v2)
|
||||
await show_surface("API v3 (version_gte='3.0')", api_v3)
|
||||
|
||||
# Same tool name, different behavior per API
|
||||
print("\n[bold]Same call through different APIs:[/]")
|
||||
for name, server in [("v1", api_v1), ("v2", api_v2), ("v3", api_v3)]:
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("process", {"data": "Hello"})
|
||||
print(f" API {name}: process('Hello') -> '{result.data}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
101
examples/versioning/versioned_components.py
Normal file
101
examples/versioning/versioned_components.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""
|
||||
Creating Versioned Components
|
||||
|
||||
Register multiple versions of the same tool, resource, or prompt.
|
||||
Clients see the highest version by default but can request specific versions.
|
||||
|
||||
Run: uv run python examples/versioning/versioned_components.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from rich import print
|
||||
from rich.table import Table
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
|
||||
mcp = FastMCP("Versioned API")
|
||||
|
||||
|
||||
# --- Versioned Tools ---
|
||||
# Same name, different versions with different signatures
|
||||
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calculate(x: int, y: int) -> int:
|
||||
"""Add two numbers (v1.0)."""
|
||||
return x + y
|
||||
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calculate(x: int, y: int, z: int = 0) -> int: # noqa: F811
|
||||
"""Add two or three numbers (v2.0)."""
|
||||
return x + y + z
|
||||
|
||||
|
||||
# --- Versioned Resources ---
|
||||
# Same URI, different content per version
|
||||
|
||||
|
||||
@mcp.resource("config://app", version="1.0")
|
||||
def config_v1() -> str:
|
||||
return '{"format": "legacy"}'
|
||||
|
||||
|
||||
@mcp.resource("config://app", version="2.0")
|
||||
def config_v2() -> str:
|
||||
return '{"format": "modern", "telemetry": true}'
|
||||
|
||||
|
||||
# --- Versioned Prompts ---
|
||||
# Same prompt, different templates per version
|
||||
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def summarize(text: str) -> str:
|
||||
return f"Summarize: {text}"
|
||||
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def summarize(text: str, style: str = "concise") -> str: # noqa: F811
|
||||
return f"Summarize in a {style} style: {text}"
|
||||
|
||||
|
||||
async def main():
|
||||
async with Client(mcp) as client:
|
||||
# List components - clients see highest version + all available versions
|
||||
tools = await client.list_tools()
|
||||
|
||||
table = Table(title="Components (as seen by clients)")
|
||||
table.add_column("Type")
|
||||
table.add_column("Name")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("All Versions", style="dim")
|
||||
|
||||
for tool in tools:
|
||||
meta = tool.meta.get("fastmcp", {}) if tool.meta else {}
|
||||
table.add_row(
|
||||
"Tool",
|
||||
tool.name,
|
||||
meta.get("version"),
|
||||
", ".join(meta.get("versions", [])),
|
||||
)
|
||||
|
||||
print(table)
|
||||
|
||||
# Call specific versions
|
||||
print("\n[bold]Calling specific versions:[/]")
|
||||
|
||||
r_default = await client.call_tool("calculate", {"x": 5, "y": 3})
|
||||
r_v1 = await client.call_tool("calculate", {"x": 5, "y": 3}, version="1.0")
|
||||
r_v2 = await client.call_tool(
|
||||
"calculate", {"x": 5, "y": 3, "z": 2}, version="2.0"
|
||||
)
|
||||
|
||||
print(f" calculate(5, 3) -> {r_default.data} (default: highest)")
|
||||
print(f" calculate(5, 3) v1.0 -> {r_v1.data}")
|
||||
print(f" calculate(5, 3, 2) v2.0 -> {r_v2.data}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -902,7 +902,7 @@ class Client(Generic[ClientTransportT]):
|
|||
params=mcp.types.ReadResourceRequestParams(
|
||||
uri=uri,
|
||||
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
|
||||
_meta=propagated_meta, # ty: ignore[unknown-argument]
|
||||
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
result = await self._await_with_session_monitoring(
|
||||
|
|
@ -922,6 +922,8 @@ class Client(Generic[ClientTransportT]):
|
|||
self,
|
||||
uri: AnyUrl | str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[False] = False,
|
||||
) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: ...
|
||||
|
||||
|
|
@ -930,6 +932,8 @@ class Client(Generic[ClientTransportT]):
|
|||
self,
|
||||
uri: AnyUrl | str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[True],
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
|
|
@ -939,6 +943,8 @@ class Client(Generic[ClientTransportT]):
|
|||
self,
|
||||
uri: AnyUrl | str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool = False,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
|
|
@ -950,6 +956,8 @@ class Client(Generic[ClientTransportT]):
|
|||
|
||||
Args:
|
||||
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
|
||||
version (str | None): Specific version to read. If None, reads highest version.
|
||||
meta (dict[str, Any] | None): Optional request-level metadata.
|
||||
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
||||
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
||||
ttl (int): Time to keep results available in milliseconds (default 60s).
|
||||
|
|
@ -962,8 +970,18 @@ class Client(Generic[ClientTransportT]):
|
|||
RuntimeError: If called while the client is not connected.
|
||||
McpError: If the request results in a TimeoutError | JSONRPCError
|
||||
"""
|
||||
# Merge version into request-level meta (not arguments)
|
||||
request_meta = dict(meta) if meta else {}
|
||||
if version is not None:
|
||||
request_meta["fastmcp"] = {
|
||||
**request_meta.get("fastmcp", {}),
|
||||
"version": version,
|
||||
}
|
||||
|
||||
if task:
|
||||
return await self._read_resource_as_task(uri, task_id, ttl)
|
||||
return await self._read_resource_as_task(
|
||||
uri, task_id, ttl, meta=request_meta or None
|
||||
)
|
||||
|
||||
if isinstance(uri, str):
|
||||
try:
|
||||
|
|
@ -972,7 +990,7 @@ class Client(Generic[ClientTransportT]):
|
|||
raise ValueError(
|
||||
f"Provided resource URI is invalid: {str(uri)!r}"
|
||||
) from e
|
||||
result = await self.read_resource_mcp(uri)
|
||||
result = await self.read_resource_mcp(uri, meta=request_meta or None)
|
||||
return result.contents
|
||||
|
||||
async def _read_resource_as_task(
|
||||
|
|
@ -980,6 +998,7 @@ class Client(Generic[ClientTransportT]):
|
|||
uri: AnyUrl | str,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ResourceTask:
|
||||
"""Read a resource for background execution (SEP-1686).
|
||||
|
||||
|
|
@ -989,6 +1008,7 @@ class Client(Generic[ClientTransportT]):
|
|||
uri: Resource URI to read
|
||||
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
||||
ttl: Time to keep results available in milliseconds (default 60s)
|
||||
meta: Optional metadata to pass with the request (e.g., version info)
|
||||
|
||||
Returns:
|
||||
ResourceTask: Future-like object for accessing task status and results
|
||||
|
|
@ -1001,6 +1021,7 @@ class Client(Generic[ClientTransportT]):
|
|||
params=mcp.types.ReadResourceRequestParams(
|
||||
uri=uri,
|
||||
task=mcp.types.TaskMetadata(ttl=ttl),
|
||||
_meta=meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1133,7 +1154,7 @@ class Client(Generic[ClientTransportT]):
|
|||
name=name,
|
||||
arguments=serialized_arguments,
|
||||
task=mcp.types.TaskMetadata(**task_dict) if task_dict else None,
|
||||
_meta=propagated_meta, # ty: ignore[unknown-argument]
|
||||
_meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
result = await self._await_with_session_monitoring(
|
||||
|
|
@ -1154,6 +1175,8 @@ class Client(Generic[ClientTransportT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[False] = False,
|
||||
) -> mcp.types.GetPromptResult: ...
|
||||
|
||||
|
|
@ -1163,6 +1186,8 @@ class Client(Generic[ClientTransportT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: Literal[True],
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
|
|
@ -1173,6 +1198,8 @@ class Client(Generic[ClientTransportT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
task: bool = False,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
|
|
@ -1182,6 +1209,8 @@ class Client(Generic[ClientTransportT]):
|
|||
Args:
|
||||
name (str): The name of the prompt to retrieve.
|
||||
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
||||
version (str | None, optional): Specific prompt version to get. If None, gets highest version.
|
||||
meta (dict[str, Any] | None): Optional request-level metadata.
|
||||
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
||||
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
||||
ttl (int): Time to keep results available in milliseconds (default 60s).
|
||||
|
|
@ -1194,10 +1223,22 @@ class Client(Generic[ClientTransportT]):
|
|||
RuntimeError: If called while the client is not connected.
|
||||
McpError: If the request results in a TimeoutError | JSONRPCError
|
||||
"""
|
||||
if task:
|
||||
return await self._get_prompt_as_task(name, arguments, task_id, ttl)
|
||||
# Merge version into request-level meta (not arguments)
|
||||
request_meta = dict(meta) if meta else {}
|
||||
if version is not None:
|
||||
request_meta["fastmcp"] = {
|
||||
**request_meta.get("fastmcp", {}),
|
||||
"version": version,
|
||||
}
|
||||
|
||||
result = await self.get_prompt_mcp(name=name, arguments=arguments)
|
||||
if task:
|
||||
return await self._get_prompt_as_task(
|
||||
name, arguments, task_id, ttl, meta=request_meta or None
|
||||
)
|
||||
|
||||
result = await self.get_prompt_mcp(
|
||||
name=name, arguments=arguments, meta=request_meta or None
|
||||
)
|
||||
return result
|
||||
|
||||
async def _get_prompt_as_task(
|
||||
|
|
@ -1206,6 +1247,7 @@ class Client(Generic[ClientTransportT]):
|
|||
arguments: dict[str, Any] | None = None,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> PromptTask:
|
||||
"""Get a prompt for background execution (SEP-1686).
|
||||
|
||||
|
|
@ -1216,6 +1258,7 @@ class Client(Generic[ClientTransportT]):
|
|||
arguments: Prompt arguments
|
||||
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
||||
ttl: Time to keep results available in milliseconds (default 60s)
|
||||
meta: Optional request metadata (e.g., version info)
|
||||
|
||||
Returns:
|
||||
PromptTask: Future-like object for accessing task status and results
|
||||
|
|
@ -1238,6 +1281,7 @@ class Client(Generic[ClientTransportT]):
|
|||
name=name,
|
||||
arguments=serialized_arguments,
|
||||
task=mcp.types.TaskMetadata(ttl=ttl),
|
||||
_meta=meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1472,6 +1516,7 @@ class Client(Generic[ClientTransportT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
timeout: datetime.timedelta | float | int | None = None,
|
||||
progress_handler: ProgressHandler | None = None,
|
||||
raise_on_error: bool = True,
|
||||
|
|
@ -1485,6 +1530,7 @@ class Client(Generic[ClientTransportT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
timeout: datetime.timedelta | float | int | None = None,
|
||||
progress_handler: ProgressHandler | None = None,
|
||||
raise_on_error: bool = True,
|
||||
|
|
@ -1499,6 +1545,7 @@ class Client(Generic[ClientTransportT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
timeout: datetime.timedelta | float | int | None = None,
|
||||
progress_handler: ProgressHandler | None = None,
|
||||
raise_on_error: bool = True,
|
||||
|
|
@ -1514,6 +1561,7 @@ class Client(Generic[ClientTransportT]):
|
|||
Args:
|
||||
name (str): The name of the tool to call.
|
||||
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
|
||||
version (str | None, optional): Specific tool version to call. If None, calls highest version.
|
||||
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
||||
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
||||
raise_on_error (bool, optional): Whether to raise an exception if the tool call results in an error. Defaults to True.
|
||||
|
|
@ -1539,15 +1587,25 @@ class Client(Generic[ClientTransportT]):
|
|||
McpError: If the tool call request results in a TimeoutError | JSONRPCError
|
||||
RuntimeError: If called while the client is not connected.
|
||||
"""
|
||||
# Merge version into request-level meta (not arguments)
|
||||
request_meta = dict(meta) if meta else {}
|
||||
if version is not None:
|
||||
request_meta["fastmcp"] = {
|
||||
**request_meta.get("fastmcp", {}),
|
||||
"version": version,
|
||||
}
|
||||
|
||||
if task:
|
||||
return await self._call_tool_as_task(name, arguments, task_id, ttl)
|
||||
return await self._call_tool_as_task(
|
||||
name, arguments, task_id, ttl, meta=request_meta or None
|
||||
)
|
||||
|
||||
result = await self.call_tool_mcp(
|
||||
name=name,
|
||||
arguments=arguments or {},
|
||||
timeout=timeout,
|
||||
progress_handler=progress_handler,
|
||||
meta=meta,
|
||||
meta=request_meta or None,
|
||||
)
|
||||
return await self._parse_call_tool_result(
|
||||
name, result, raise_on_error=raise_on_error
|
||||
|
|
@ -1559,6 +1617,7 @@ class Client(Generic[ClientTransportT]):
|
|||
arguments: dict[str, Any] | None = None,
|
||||
task_id: str | None = None,
|
||||
ttl: int = 60000,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ToolTask:
|
||||
"""Call a tool for background execution (SEP-1686).
|
||||
|
||||
|
|
@ -1571,6 +1630,7 @@ class Client(Generic[ClientTransportT]):
|
|||
arguments: Tool arguments
|
||||
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
||||
ttl: Time to keep results available in milliseconds (default 60s)
|
||||
meta: Optional request metadata (e.g., version info)
|
||||
|
||||
Returns:
|
||||
ToolTask: Future-like object for accessing task status and results
|
||||
|
|
@ -1582,6 +1642,7 @@ class Client(Generic[ClientTransportT]):
|
|||
name=name,
|
||||
arguments=arguments or {},
|
||||
task=mcp.types.TaskMetadata(ttl=ttl),
|
||||
_meta=meta, # type: ignore[unknown-argument] # pydantic alias
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from contextlib import (
|
|||
from dataclasses import replace
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
|
@ -103,7 +103,11 @@ from fastmcp.utilities.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
|
||||
from fastmcp.utilities.versions import (
|
||||
VersionSpec,
|
||||
parse_version_key,
|
||||
version_sort_key,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from docket import Docket
|
||||
|
|
@ -187,6 +191,54 @@ def _get_auth_context() -> tuple[bool, Any]:
|
|||
return (False, get_access_token())
|
||||
|
||||
|
||||
C = TypeVar("C", bound="FastMCPComponent")
|
||||
|
||||
|
||||
def _dedupe_with_versions(
|
||||
components: Sequence[C],
|
||||
key_fn: Callable[[C], str],
|
||||
) -> list[C]:
|
||||
"""Deduplicate components by key, keeping highest version.
|
||||
|
||||
Groups components by key, selects the highest version from each group,
|
||||
and injects available versions into meta if any component is versioned.
|
||||
|
||||
Args:
|
||||
components: Sequence of components to deduplicate.
|
||||
key_fn: Function to extract the grouping key from a component.
|
||||
|
||||
Returns:
|
||||
Deduplicated list with versions injected into meta.
|
||||
"""
|
||||
by_key: dict[str, list[C]] = {}
|
||||
for c in components:
|
||||
by_key.setdefault(key_fn(c), []).append(c)
|
||||
|
||||
result: list[C] = []
|
||||
for versions in by_key.values():
|
||||
highest: C = cast(C, max(versions, key=version_sort_key))
|
||||
if any(c.version is not None for c in versions):
|
||||
all_versions = sorted(
|
||||
[c.version for c in versions if c.version is not None],
|
||||
key=parse_version_key,
|
||||
reverse=True,
|
||||
)
|
||||
meta = highest.meta or {}
|
||||
highest = highest.model_copy(
|
||||
update={
|
||||
"meta": {
|
||||
**meta,
|
||||
"fastmcp": {
|
||||
**meta.get("fastmcp", {}),
|
||||
"versions": all_versions,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
result.append(highest)
|
||||
return result
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
|
||||
"""Default lifespan context manager that does nothing.
|
||||
|
|
@ -1179,23 +1231,19 @@ 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 name (keeping highest version) and apply authorization checks
|
||||
by_name: dict[str, Tool] = {}
|
||||
# Filter by auth
|
||||
authorized: list[Tool] = []
|
||||
for tool in tools:
|
||||
# Check tool-level auth (skip for STDIO)
|
||||
if not skip_auth and tool.auth is not None:
|
||||
ctx = AuthContext(token=token, component=tool)
|
||||
try:
|
||||
if not run_auth_checks(tool.auth, ctx):
|
||||
continue
|
||||
except AuthorizationError:
|
||||
# Treat auth errors as denials in list operations
|
||||
continue
|
||||
# Keep highest version per name
|
||||
existing = by_name.get(tool.name)
|
||||
if existing is None or version_sort_key(tool) > version_sort_key(existing):
|
||||
by_name[tool.name] = tool
|
||||
return list(by_name.values())
|
||||
authorized.append(tool)
|
||||
|
||||
return _dedupe_with_versions(authorized, lambda t: t.name)
|
||||
|
||||
async def get_tool(
|
||||
self, name: str, version: VersionSpec | str | None = None
|
||||
|
|
@ -1276,26 +1324,19 @@ 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 URI (keeping highest version) and apply authorization checks
|
||||
by_uri: dict[str, Resource] = {}
|
||||
# Filter by auth
|
||||
authorized: list[Resource] = []
|
||||
for resource in resources:
|
||||
# Check resource-level auth (skip for STDIO)
|
||||
if not skip_auth and resource.auth is not None:
|
||||
ctx = AuthContext(token=token, component=resource)
|
||||
try:
|
||||
if not run_auth_checks(resource.auth, ctx):
|
||||
continue
|
||||
except AuthorizationError:
|
||||
# Treat auth errors as denials in list operations
|
||||
continue
|
||||
# Keep highest version per URI
|
||||
uri_str = str(resource.uri)
|
||||
existing = by_uri.get(uri_str)
|
||||
if existing is None or version_sort_key(resource) > version_sort_key(
|
||||
existing
|
||||
):
|
||||
by_uri[uri_str] = resource
|
||||
return list(by_uri.values())
|
||||
authorized.append(resource)
|
||||
|
||||
return _dedupe_with_versions(authorized, lambda r: str(r.uri))
|
||||
|
||||
async def get_resource(
|
||||
self, uri: str, version: VersionSpec | str | None = None
|
||||
|
|
@ -1378,25 +1419,19 @@ 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 uri_template (keeping highest version) and apply authorization checks
|
||||
by_uri_template: dict[str, ResourceTemplate] = {}
|
||||
# Filter by auth
|
||||
authorized: list[ResourceTemplate] = []
|
||||
for template in templates:
|
||||
# Check template-level auth (skip for STDIO)
|
||||
if not skip_auth and template.auth is not None:
|
||||
ctx = AuthContext(token=token, component=template)
|
||||
try:
|
||||
if not run_auth_checks(template.auth, ctx):
|
||||
continue
|
||||
except AuthorizationError:
|
||||
# Treat auth errors as denials in list operations
|
||||
continue
|
||||
# Keep highest version per uri_template
|
||||
existing = by_uri_template.get(template.uri_template)
|
||||
if existing is None or version_sort_key(template) > version_sort_key(
|
||||
existing
|
||||
):
|
||||
by_uri_template[template.uri_template] = template
|
||||
return list(by_uri_template.values())
|
||||
authorized.append(template)
|
||||
|
||||
return _dedupe_with_versions(authorized, lambda t: t.uri_template)
|
||||
|
||||
async def get_resource_template(
|
||||
self, uri: str, version: VersionSpec | str | None = None
|
||||
|
|
@ -1485,25 +1520,19 @@ 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 name (keeping highest version) and apply authorization checks
|
||||
by_name: dict[str, Prompt] = {}
|
||||
# Filter by auth
|
||||
authorized: list[Prompt] = []
|
||||
for prompt in prompts:
|
||||
# Check prompt-level auth (skip for STDIO)
|
||||
if not skip_auth and prompt.auth is not None:
|
||||
ctx = AuthContext(token=token, component=prompt)
|
||||
try:
|
||||
if not run_auth_checks(prompt.auth, ctx):
|
||||
continue
|
||||
except AuthorizationError:
|
||||
# Treat auth errors as denials in list operations
|
||||
continue
|
||||
# Keep highest version per name
|
||||
existing = by_name.get(prompt.name)
|
||||
if existing is None or version_sort_key(prompt) > version_sort_key(
|
||||
existing
|
||||
):
|
||||
by_name[prompt.name] = prompt
|
||||
return list(by_name.values())
|
||||
authorized.append(prompt)
|
||||
|
||||
return _dedupe_with_versions(authorized, lambda p: p.name)
|
||||
|
||||
async def get_prompt(
|
||||
self, name: str, version: VersionSpec | str | None = None
|
||||
|
|
@ -1558,6 +1587,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: None = None,
|
||||
) -> ToolResult: ...
|
||||
|
|
@ -1568,6 +1598,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
|
@ -1577,6 +1608,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> ToolResult | mcp.types.CreateTaskResult:
|
||||
|
|
@ -1587,6 +1619,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
Args:
|
||||
name: The tool name
|
||||
arguments: Tool arguments (optional)
|
||||
version: Specific version to call. If None, calls highest version.
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
task_meta: If provided, execute as a background task and return
|
||||
|
|
@ -1622,6 +1655,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
call_next=lambda context: self.call_tool(
|
||||
context.message.name,
|
||||
context.message.arguments or {},
|
||||
version=version,
|
||||
run_middleware=False,
|
||||
task_meta=task_meta,
|
||||
),
|
||||
|
|
@ -1631,7 +1665,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
with server_span(
|
||||
f"tools/call {name}", "tools/call", self.name, "tool", name
|
||||
) as span:
|
||||
tool = await self.get_tool(name)
|
||||
tool = await self.get_tool(name, version=version)
|
||||
span.set_attributes(tool.get_span_attributes())
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=tool.key)
|
||||
|
|
@ -1654,6 +1688,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
self,
|
||||
uri: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: None = None,
|
||||
) -> ResourceResult: ...
|
||||
|
|
@ -1663,6 +1698,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
self,
|
||||
uri: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
|
@ -1671,6 +1707,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
self,
|
||||
uri: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> ResourceResult | mcp.types.CreateTaskResult:
|
||||
|
|
@ -1681,6 +1718,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
Args:
|
||||
uri: The resource URI
|
||||
version: Specific version to read. If None, reads highest version.
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
task_meta: If provided, execute as a background task and return
|
||||
|
|
@ -1716,6 +1754,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
context=mw_context,
|
||||
call_next=lambda context: self.read_resource(
|
||||
str(context.message.uri),
|
||||
version=version,
|
||||
run_middleware=False,
|
||||
task_meta=task_meta,
|
||||
),
|
||||
|
|
@ -1732,7 +1771,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
) as span:
|
||||
# Try concrete resources first
|
||||
try:
|
||||
resource = await self.get_resource(uri)
|
||||
resource = await self.get_resource(uri, version=version)
|
||||
span.set_attributes(resource.get_span_attributes())
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=resource.key)
|
||||
|
|
@ -1750,9 +1789,13 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
# Try templates
|
||||
try:
|
||||
template = await self.get_resource_template(uri)
|
||||
template = await self.get_resource_template(uri, version=version)
|
||||
except NotFoundError:
|
||||
raise NotFoundError(f"Unknown resource: {uri!r}") from None
|
||||
if version is None:
|
||||
raise NotFoundError(f"Unknown resource: {uri!r}") from None
|
||||
raise NotFoundError(
|
||||
f"Unknown resource: {uri!r} version {version!r}"
|
||||
) from None
|
||||
span.set_attributes(template.get_span_attributes())
|
||||
params = template.matches(uri)
|
||||
assert params is not None
|
||||
|
|
@ -1775,6 +1818,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: None = None,
|
||||
) -> PromptResult: ...
|
||||
|
|
@ -1785,6 +1829,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta,
|
||||
) -> mcp.types.CreateTaskResult: ...
|
||||
|
|
@ -1794,6 +1839,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
*,
|
||||
version: str | None = None,
|
||||
run_middleware: bool = True,
|
||||
task_meta: TaskMeta | None = None,
|
||||
) -> PromptResult | mcp.types.CreateTaskResult:
|
||||
|
|
@ -1805,6 +1851,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
Args:
|
||||
name: The prompt name
|
||||
arguments: Prompt arguments (optional)
|
||||
version: Specific version to render. If None, renders highest version.
|
||||
run_middleware: If True (default), apply the middleware chain.
|
||||
Set to False when called from middleware to avoid re-applying.
|
||||
task_meta: If provided, execute as a background task and return
|
||||
|
|
@ -1835,6 +1882,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
call_next=lambda context: self.render_prompt(
|
||||
context.message.name,
|
||||
context.message.arguments,
|
||||
version=version,
|
||||
run_middleware=False,
|
||||
task_meta=task_meta,
|
||||
),
|
||||
|
|
@ -1844,7 +1892,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
with server_span(
|
||||
f"prompts/get {name}", "prompts/get", self.name, "prompt", name
|
||||
) as span:
|
||||
prompt = await self.get_prompt(name)
|
||||
prompt = await self.get_prompt(name, version=version)
|
||||
span.set_attributes(prompt.get_span_attributes())
|
||||
if task_meta is not None and task_meta.fn_key is None:
|
||||
task_meta = replace(task_meta, fn_key=prompt.key)
|
||||
|
|
@ -2011,11 +2059,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
|
||||
try:
|
||||
# Extract SEP-1686 task metadata from request context.
|
||||
# Extract version and task metadata from request context.
|
||||
# fn_key is set by call_tool() after finding the tool.
|
||||
version: str | None = None
|
||||
task_meta: TaskMeta | None = None
|
||||
try:
|
||||
ctx = self._mcp_server.request_context
|
||||
# Extract version from request-level _meta.fastmcp.version
|
||||
if ctx.meta:
|
||||
meta_dict = ctx.meta.model_dump(exclude_none=True)
|
||||
version = meta_dict.get("fastmcp", {}).get("version")
|
||||
# Extract SEP-1686 task metadata
|
||||
if ctx.experimental.is_task:
|
||||
mcp_task_meta = ctx.experimental.task_metadata
|
||||
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
|
||||
|
|
@ -2023,7 +2077,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
except (AttributeError, LookupError):
|
||||
pass
|
||||
|
||||
result = await self.call_tool(key, arguments, task_meta=task_meta)
|
||||
result = await self.call_tool(
|
||||
key, arguments, version=version, task_meta=task_meta
|
||||
)
|
||||
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
|
|
@ -2052,11 +2108,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
logger.debug(f"[{self.name}] Handler called: read_resource %s", uri)
|
||||
|
||||
try:
|
||||
# Extract SEP-1686 task metadata from request context.
|
||||
# fn_key is set by read_resource() after finding the resource/template.
|
||||
# Extract version and task metadata from request context.
|
||||
version: str | None = None
|
||||
task_meta: TaskMeta | None = None
|
||||
try:
|
||||
ctx = self._mcp_server.request_context
|
||||
# Extract version from _meta.fastmcp.version if provided
|
||||
if ctx.meta:
|
||||
meta_dict = ctx.meta.model_dump(exclude_none=True)
|
||||
fastmcp_meta = meta_dict.get("fastmcp") or {}
|
||||
version = fastmcp_meta.get("version")
|
||||
# Extract SEP-1686 task metadata
|
||||
if ctx.experimental.is_task:
|
||||
mcp_task_meta = ctx.experimental.task_metadata
|
||||
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
|
||||
|
|
@ -2064,7 +2126,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
except (AttributeError, LookupError):
|
||||
pass
|
||||
|
||||
result = await self.read_resource(str(uri), task_meta=task_meta)
|
||||
result = await self.read_resource(
|
||||
str(uri), version=version, task_meta=task_meta
|
||||
)
|
||||
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
|
|
@ -2095,11 +2159,17 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
|
||||
try:
|
||||
# Extract SEP-1686 task metadata from request context.
|
||||
# Extract version and task metadata from request context.
|
||||
# fn_key is set by render_prompt() after finding the prompt.
|
||||
version: str | None = None
|
||||
task_meta: TaskMeta | None = None
|
||||
try:
|
||||
ctx = self._mcp_server.request_context
|
||||
# Extract version from request-level _meta.fastmcp.version
|
||||
if ctx.meta:
|
||||
meta_dict = ctx.meta.model_dump(exclude_none=True)
|
||||
version = meta_dict.get("fastmcp", {}).get("version")
|
||||
# Extract SEP-1686 task metadata
|
||||
if ctx.experimental.is_task:
|
||||
mcp_task_meta = ctx.experimental.task_metadata
|
||||
task_meta_dict = mcp_task_meta.model_dump(exclude_none=True)
|
||||
|
|
@ -2107,7 +2177,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
except (AttributeError, LookupError):
|
||||
pass
|
||||
|
||||
result = await self.render_prompt(name, arguments, task_meta=task_meta)
|
||||
result = await self.render_prompt(
|
||||
name, arguments, version=version, task_meta=task_meta
|
||||
)
|
||||
|
||||
if isinstance(result, mcp.types.CreateTaskResult):
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -988,3 +988,266 @@ class TestVersionValidation:
|
|||
@mcp.tool(version="1.0@beta")
|
||||
def my_tool() -> str:
|
||||
return "test"
|
||||
|
||||
|
||||
class TestVersionMetadata:
|
||||
"""Tests for version metadata exposure in list operations."""
|
||||
|
||||
async def test_tool_versions_in_meta(self):
|
||||
"""List tools should include versions list in meta."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def add(x: int, y: int) -> int: # noqa: F811
|
||||
return x + y
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def add(x: int, y: int) -> int: # noqa: F811
|
||||
return x + y
|
||||
|
||||
tools = await mcp.get_tools()
|
||||
assert len(tools) == 1
|
||||
|
||||
tool = tools[0]
|
||||
meta = tool.get_meta()
|
||||
assert meta["fastmcp"]["version"] == "2.0"
|
||||
assert meta["fastmcp"]["versions"] == ["2.0", "1.0"]
|
||||
|
||||
async def test_resource_versions_in_meta(self):
|
||||
"""List resources should include versions list in meta."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("data://config", version="1.0")
|
||||
def config_v1() -> str: # noqa: F811
|
||||
return "v1"
|
||||
|
||||
@mcp.resource("data://config", version="2.0")
|
||||
def config_v2() -> str: # noqa: F811
|
||||
return "v2"
|
||||
|
||||
resources = await mcp.get_resources()
|
||||
assert len(resources) == 1
|
||||
|
||||
resource = resources[0]
|
||||
meta = resource.get_meta()
|
||||
assert meta["fastmcp"]["version"] == "2.0"
|
||||
assert meta["fastmcp"]["versions"] == ["2.0", "1.0"]
|
||||
|
||||
async def test_prompt_versions_in_meta(self):
|
||||
"""List prompts should include versions list in meta."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello v1"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello v2"
|
||||
|
||||
prompts = await mcp.get_prompts()
|
||||
assert len(prompts) == 1
|
||||
|
||||
prompt = prompts[0]
|
||||
meta = prompt.get_meta()
|
||||
assert meta["fastmcp"]["version"] == "2.0"
|
||||
assert meta["fastmcp"]["versions"] == ["2.0", "1.0"]
|
||||
|
||||
async def test_unversioned_no_versions_list(self):
|
||||
"""Unversioned components should not have versions list in meta."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool
|
||||
def simple() -> str:
|
||||
return "simple"
|
||||
|
||||
tools = await mcp.get_tools()
|
||||
assert len(tools) == 1
|
||||
|
||||
tool = tools[0]
|
||||
meta = tool.get_meta()
|
||||
assert "versions" not in meta.get("fastmcp", {})
|
||||
|
||||
|
||||
class TestVersionedCalls:
|
||||
"""Tests for calling specific component versions."""
|
||||
|
||||
async def test_call_tool_with_version(self):
|
||||
"""call_tool should use specified version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calculate(x: int, y: int) -> int: # noqa: F811
|
||||
return x + y
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calculate(x: int, y: int) -> int: # noqa: F811
|
||||
return x * y
|
||||
|
||||
# Default: highest version (2.0, multiplication)
|
||||
result = await mcp.call_tool("calculate", {"x": 3, "y": 4})
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["result"] == 12
|
||||
|
||||
# Explicit v1.0 (addition)
|
||||
result = await mcp.call_tool("calculate", {"x": 3, "y": 4}, version="1.0")
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["result"] == 7
|
||||
|
||||
# Explicit v2.0 (multiplication)
|
||||
result = await mcp.call_tool("calculate", {"x": 3, "y": 4}, version="2.0")
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["result"] == 12
|
||||
|
||||
async def test_read_resource_with_version(self):
|
||||
"""read_resource should use specified version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("data://config", version="1.0")
|
||||
def config() -> str: # noqa: F811
|
||||
return "config v1"
|
||||
|
||||
@mcp.resource("data://config", version="2.0")
|
||||
def config() -> str: # noqa: F811
|
||||
return "config v2"
|
||||
|
||||
# Default: highest version
|
||||
result = await mcp.read_resource("data://config")
|
||||
assert result.contents[0].content == "config v2"
|
||||
|
||||
# Explicit v1.0
|
||||
result = await mcp.read_resource("data://config", version="1.0")
|
||||
assert result.contents[0].content == "config v1"
|
||||
|
||||
async def test_render_prompt_with_version(self):
|
||||
"""render_prompt should use specified version."""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello from v1"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet() -> str: # noqa: F811
|
||||
return "Hello from v2"
|
||||
|
||||
# Default: highest version
|
||||
result = await mcp.render_prompt("greet")
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and content.text == "Hello from v2"
|
||||
|
||||
# Explicit v1.0
|
||||
result = await mcp.render_prompt("greet", version="1.0")
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and content.text == "Hello from v1"
|
||||
|
||||
async def test_call_tool_invalid_version_not_found(self):
|
||||
"""Calling with non-existent version should raise NotFoundError."""
|
||||
import pytest
|
||||
|
||||
from fastmcp.exceptions import NotFoundError
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def mytool() -> str:
|
||||
return "v1"
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
await mcp.call_tool("mytool", {}, version="999.0")
|
||||
|
||||
|
||||
class TestClientVersionSelection:
|
||||
"""Tests for client-side version selection via the version parameter.
|
||||
|
||||
Version selection flows through request-level _meta, not arguments.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
(None, 10), # Default: highest version (2.0) -> 5 * 2
|
||||
("1.0", 6), # v1.0 -> 5 + 1
|
||||
("2.0", 10), # v2.0 -> 5 * 2
|
||||
],
|
||||
)
|
||||
async def test_call_tool_version_selection(
|
||||
self, version: str | None, expected: int
|
||||
):
|
||||
"""Client.call_tool routes to correct version via request meta."""
|
||||
from fastmcp import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.tool(version="1.0")
|
||||
def calc(x: int) -> int: # noqa: F811
|
||||
return x + 1
|
||||
|
||||
@mcp.tool(version="2.0")
|
||||
def calc(x: int) -> int: # noqa: F811
|
||||
return x * 2
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("calc", {"x": 5}, version=version)
|
||||
assert result.data == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
(None, "Hello world from v2"), # Default: highest version
|
||||
("1.0", "Hello world from v1"),
|
||||
("2.0", "Hello world from v2"),
|
||||
],
|
||||
)
|
||||
async def test_get_prompt_version_selection(
|
||||
self, version: str | None, expected: str
|
||||
):
|
||||
"""Client.get_prompt routes to correct version via request meta."""
|
||||
from fastmcp import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.prompt(version="1.0")
|
||||
def greet(name: str) -> str: # noqa: F811
|
||||
return f"Hello {name} from v1"
|
||||
|
||||
@mcp.prompt(version="2.0")
|
||||
def greet(name: str) -> str: # noqa: F811
|
||||
return f"Hello {name} from v2"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.get_prompt(
|
||||
"greet", {"name": "world"}, version=version
|
||||
)
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent) and content.text == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version,expected",
|
||||
[
|
||||
(None, "v2 data"), # Default: highest version
|
||||
("1.0", "v1 data"),
|
||||
("2.0", "v2 data"),
|
||||
],
|
||||
)
|
||||
async def test_read_resource_version_selection(
|
||||
self, version: str | None, expected: str
|
||||
):
|
||||
"""Client.read_resource routes to correct version via request meta."""
|
||||
from fastmcp import Client
|
||||
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("data://info", version="1.0")
|
||||
def info_v1() -> str: # noqa: F811
|
||||
return "v1 data"
|
||||
|
||||
@mcp.resource("data://info", version="2.0")
|
||||
def info_v2() -> str: # noqa: F811
|
||||
return "v2 data"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("data://info", version=version)
|
||||
assert result[0].text == expected # type: ignore[union-attr]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue