From f08205789eca4321fc992b3ec23557e5529d729b Mon Sep 17 00:00:00 2001 From: Martim Santos Date: Fri, 13 Mar 2026 21:33:50 +0000 Subject: [PATCH] fix: add version to components (#3458) --- .../server/providers/filesystem_discovery.py | 4 + tests/fs/test_discovery.py | 136 +++++++++++++++++- tests/fs/test_provider.py | 102 +++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/server/providers/filesystem_discovery.py b/src/fastmcp/server/providers/filesystem_discovery.py index 96fe81b81..d78bddb4b 100644 --- a/src/fastmcp/server/providers/filesystem_discovery.py +++ b/src/fastmcp/server/providers/filesystem_discovery.py @@ -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, diff --git a/tests/fs/test_discovery.py b/tests/fs/test_discovery.py index 261b93401..535650d32 100644 --- a/tests/fs/test_discovery.py +++ b/tests/fs/test_discovery.py @@ -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"] diff --git a/tests/fs/test_provider.py b/tests/fs/test_provider.py index 03518e795..37184a815 100644 --- a/tests/fs/test_provider.py +++ b/tests/fs/test_provider.py @@ -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"]