fix: add version to components (#3458)

This commit is contained in:
Martim Santos 2026-03-13 21:33:50 +00:00 committed by GitHub
commit f08205789e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 241 additions and 1 deletions

View file

@ -224,6 +224,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
tool = Tool.from_function(
obj,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@ -248,6 +249,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
fn=obj,
uri_template=meta.uri,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@ -263,6 +265,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
fn=obj,
uri=meta.uri,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
@ -279,6 +282,7 @@ def extract_components(module: ModuleType) -> list[FastMCPComponent]:
prompt = Prompt.from_function(
obj,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,

View file

@ -2,7 +2,9 @@
from pathlib import Path
from fastmcp.resources.template import FunctionResourceTemplate
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
from fastmcp.server.providers.filesystem_discovery import (
discover_and_import,
discover_files,
@ -10,6 +12,7 @@ from fastmcp.server.providers.filesystem_discovery import (
import_module_from_file,
)
from fastmcp.tools import FunctionTool
from fastmcp.tools.tool import Tool
class TestDiscoverFiles:
@ -351,3 +354,134 @@ def bad_function():
failed_path = tmp_path / "bad.py"
assert failed_path in result.failed_files
assert "nonexistent_module_xyz123" in result.failed_files[failed_path]
class TestExtractComponentsVersion:
"""Tests for version propagation in extract_components."""
def test_extract_tool_preserves_version(self, tmp_path: Path):
"""Tools discovered from files should have their version attribute set."""
tool_file = tmp_path / "versioned_tool.py"
tool_file.write_text(
"""\
from fastmcp.tools import tool
@tool(version="1.0", description="v1")
def greet_v1(name: str) -> str:
return f"Hi {name}"
@tool(version="2.0", description="v2")
def greet_v2(name: str) -> str:
return f"Hey {name}"
"""
)
module = import_module_from_file(tool_file)
components = extract_components(module)
tools = [c for c in components if isinstance(c, Tool)]
assert len(tools) == 2
versions = {t.version for t in tools}
assert versions == {"1.0", "2.0"}
def test_extract_resource_preserves_version(self, tmp_path: Path):
"""Resources discovered from files should have their version attribute set."""
resource_file = tmp_path / "versioned_resource.py"
resource_file.write_text(
"""\
from fastmcp.resources import resource
@resource("data://config", version="1.0", name="config", description="v1 config")
def config_v1() -> str:
return '{"theme": "light"}'
"""
)
module = import_module_from_file(resource_file)
components = extract_components(module)
resources = [c for c in components if isinstance(c, Resource)]
assert len(resources) == 1
assert resources[0].version == "1.0"
def test_extract_resource_template_preserves_version(self, tmp_path: Path):
"""Resource templates discovered from files should have their version set."""
template_file = tmp_path / "versioned_template.py"
template_file.write_text(
"""\
from fastmcp.resources import resource
@resource("users://{user_id}/profile", version="2.0", description="v2 profile")
def get_profile(user_id: str) -> dict:
return {"id": user_id}
"""
)
module = import_module_from_file(template_file)
components = extract_components(module)
templates = [c for c in components if isinstance(c, ResourceTemplate)]
assert len(templates) == 1
assert templates[0].version == "2.0"
def test_extract_prompt_preserves_version(self, tmp_path: Path):
"""Prompts discovered from files should have their version attribute set."""
prompt_file = tmp_path / "versioned_prompt.py"
prompt_file.write_text(
"""\
from fastmcp.prompts import prompt
@prompt(name="summarize", version="1.0", description="v1 prompt")
def summarize_v1(text: str) -> str:
return f"Summarize: {text}"
"""
)
module = import_module_from_file(prompt_file)
components = extract_components(module)
prompts = [c for c in components if isinstance(c, Prompt)]
assert len(prompts) == 1
assert prompts[0].version == "1.0"
def test_discovered_tool_meta_includes_version(self, tmp_path: Path):
"""get_meta() should include version for tools discovered via filesystem."""
tool_file = tmp_path / "meta_tool.py"
tool_file.write_text(
"""\
from fastmcp.tools import tool
@tool(name="echo", version="3.0", description="Echo tool")
def echo(msg: str) -> str:
return msg
"""
)
module = import_module_from_file(tool_file)
components = extract_components(module)
tool = components[0]
meta = tool.get_meta()
assert meta["fastmcp"]["version"] == "3.0"
def test_unversioned_components_have_no_version(self, tmp_path: Path):
"""Components without version should have version=None."""
tool_file = tmp_path / "no_version_tool.py"
tool_file.write_text(
"""\
from fastmcp.tools import tool
@tool(description="No version")
def my_tool(x: str) -> str:
return x
"""
)
module = import_module_from_file(tool_file)
components = extract_components(module)
assert len(components) == 1
assert components[0].version is None
meta = components[0].get_meta()
assert "version" not in meta["fastmcp"]

View file

@ -419,3 +419,105 @@ def charge(amount: float) -> str:
assert len(tools_list) == 2
names = {t.name for t in tools_list}
assert names == {"greet", "charge"}
class TestFileSystemProviderVersioning:
"""Tests for version propagation through FileSystemProvider."""
async def test_versioned_tool_via_provider(self, tmp_path: Path):
"""FileSystemProvider should preserve tool version in list_tools output."""
(tmp_path / "versioned.py").write_text(
"""\
from fastmcp.tools import tool
@tool(version="1.0", description="v1 greet")
def greet(name: str) -> str:
return f"Hello, {name}!"
"""
)
provider = FileSystemProvider(tmp_path)
mcp = FastMCP("TestServer", providers=[provider])
async with Client(mcp) as client:
tools = await client.list_tools()
assert len(tools) == 1
assert tools[0].name == "greet"
meta = tools[0].meta
assert meta is not None
assert meta["fastmcp"]["version"] == "1.0"
async def test_versioned_resource_via_provider(self, tmp_path: Path):
"""FileSystemProvider should preserve resource version."""
(tmp_path / "versioned_resource.py").write_text(
"""\
from fastmcp.resources import resource
@resource("data://config", version="2.0", name="config", description="v2 config")
def config() -> str:
return '{"theme": "dark"}'
"""
)
provider = FileSystemProvider(tmp_path)
mcp = FastMCP("TestServer", providers=[provider])
async with Client(mcp) as client:
resources = await client.list_resources()
assert len(resources) == 1
assert resources[0].name == "config"
meta = resources[0].meta
assert meta is not None
assert meta["fastmcp"]["version"] == "2.0"
async def test_versioned_prompt_via_provider(self, tmp_path: Path):
"""FileSystemProvider should preserve prompt version."""
(tmp_path / "versioned_prompt.py").write_text(
"""\
from fastmcp.prompts import prompt
@prompt(name="summarize", version="1.0", description="v1 prompt")
def summarize(text: str) -> str:
return f"Summarize: {text}"
"""
)
provider = FileSystemProvider(tmp_path)
mcp = FastMCP("TestServer", providers=[provider])
async with Client(mcp) as client:
prompts = await client.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "summarize"
meta = prompts[0].meta
assert meta is not None
assert meta["fastmcp"]["version"] == "1.0"
async def test_multiple_tool_versions_via_provider(self, tmp_path: Path):
"""FileSystemProvider should handle multiple versions of the same tool."""
(tmp_path / "multi_version.py").write_text(
"""\
from fastmcp.tools import tool
@tool(name="add", version="1.0", description="v1 add")
def add_v1(x: int, y: int) -> int:
return x + y
@tool(name="add", version="2.0", description="v2 add with z")
def add_v2(x: int, y: int, z: int = 0) -> int:
return x + y + z
"""
)
provider = FileSystemProvider(tmp_path)
mcp = FastMCP("TestServer", providers=[provider])
async with Client(mcp) as client:
tools = await client.list_tools()
add_tools = [t for t in tools if t.name == "add"]
# list_tools deduplicates to the highest version
assert len(add_tools) == 1
meta = add_tools[0].meta
assert meta is not None
assert meta["fastmcp"]["version"] == "2.0"
assert meta["fastmcp"]["versions"] == ["2.0", "1.0"]