"""Tests for FileSystemProvider.""" import asyncio import time from pathlib import Path from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.server.providers import FileSystemProvider class TestFileSystemProvider: """Tests for FileSystemProvider.""" def test_provider_empty_directory(self, tmp_path: Path): """Provider should work with empty directory.""" provider = FileSystemProvider(tmp_path) assert repr(provider).startswith("FileSystemProvider") def test_provider_discovers_tools(self, tmp_path: Path): """Provider should discover @tool decorated functions.""" tools_dir = tmp_path / "tools" tools_dir.mkdir() (tools_dir / "greet.py").write_text( """\ from fastmcp.tools import tool @tool def greet(name: str) -> str: '''Greet someone by name.''' return f"Hello, {name}!" """ ) provider = FileSystemProvider(tmp_path) # Check tool was registered assert len(provider._components) == 1 def test_provider_discovers_resources(self, tmp_path: Path): """Provider should discover @resource decorated functions.""" (tmp_path / "config.py").write_text( """\ from fastmcp.resources import resource @resource("config://app") def get_config() -> dict: '''Get app config.''' return {"setting": "value"} """ ) provider = FileSystemProvider(tmp_path) assert len(provider._components) == 1 def test_provider_discovers_resource_templates(self, tmp_path: Path): """Provider should discover resource templates.""" (tmp_path / "users.py").write_text( """\ from fastmcp.resources import resource @resource("users://{user_id}/profile") def get_profile(user_id: str) -> dict: '''Get user profile.''' return {"id": user_id} """ ) provider = FileSystemProvider(tmp_path) assert len(provider._components) == 1 def test_provider_discovers_prompts(self, tmp_path: Path): """Provider should discover @prompt decorated functions.""" (tmp_path / "analyze.py").write_text( """\ from fastmcp.prompts import prompt @prompt def analyze(topic: str) -> list: '''Analyze a topic.''' return [{"role": "user", "content": f"Analyze: {topic}"}] """ ) provider = FileSystemProvider(tmp_path) assert len(provider._components) == 1 def test_provider_discovers_multiple_in_one_file(self, tmp_path: Path): """Provider should discover multiple components in one file.""" (tmp_path / "multi.py").write_text( """\ from fastmcp.tools import tool from fastmcp.resources import resource @tool def tool1() -> str: return "tool1" @tool def tool2() -> str: return "tool2" @resource("config://app") def get_config() -> dict: return {} """ ) provider = FileSystemProvider(tmp_path) assert len(provider._components) == 3 def test_provider_skips_undecorated_files(self, tmp_path: Path): """Provider should skip files with no decorated functions.""" (tmp_path / "utils.py").write_text( """\ def helper_function(): return "helper" SOME_CONSTANT = 42 """ ) (tmp_path / "tool.py").write_text( """\ from fastmcp.tools import tool @tool def my_tool() -> str: return "tool" """ ) provider = FileSystemProvider(tmp_path) # Only the tool should be registered assert len(provider._components) == 1 class TestFileSystemProviderReloadMode: """Tests for FileSystemProvider reload mode.""" def test_reload_false_caches_at_init(self, tmp_path: Path): """With reload=False, components are cached at init.""" (tmp_path / "tool.py").write_text( """\ from fastmcp.tools import tool @tool def original() -> str: return "original" """ ) provider = FileSystemProvider(tmp_path, reload=False) assert len(provider._components) == 1 # Add another file - should NOT be picked up (tmp_path / "tool2.py").write_text( """\ from fastmcp.tools import tool @tool def added() -> str: return "added" """ ) # Still only one component assert len(provider._components) == 1 async def test_reload_true_rescans(self, tmp_path: Path): """With reload=True, components are rescanned on each request.""" (tmp_path / "tool.py").write_text( """\ from fastmcp.tools import tool @tool def original() -> str: return "original" """ ) provider = FileSystemProvider(tmp_path, reload=True) # Always loaded once at init (to catch errors early) assert provider._loaded assert len(provider._components) == 1 # Add another file - should be picked up on next read (tmp_path / "tool2.py").write_text( """\ from fastmcp.tools import tool @tool def added() -> str: return "added" """ ) # With reload=True, reading triggers a re-scan tools = await provider._list_tools() assert len(tools) == 2 async def test_warning_deduplication_same_file(self, tmp_path: Path, capsys): """Warnings for the same broken file should not repeat.""" bad_file = tmp_path / "bad.py" bad_file.write_text("1/0 # division by zero") provider = FileSystemProvider(tmp_path, reload=True) # First load - should warn captured = capsys.readouterr() # Check for warning indicator (rich may truncate long paths) assert "WARNING" in captured.err and "Failed to import" in captured.err # Second load (same file, unchanged) - should NOT warn again await provider._list_tools() captured = capsys.readouterr() assert "Failed to import" not in captured.err async def test_warning_on_file_change(self, tmp_path: Path, capsys): """Warnings should reappear when a broken file changes.""" bad_file = tmp_path / "bad.py" bad_file.write_text("1/0 # division by zero") provider = FileSystemProvider(tmp_path, reload=True) # First load - should warn captured = capsys.readouterr() # Check for warning indicator (rich may truncate long paths) assert "WARNING" in captured.err and "Failed to import" in captured.err # Modify the file (different error) - need to ensure mtime changes time.sleep(0.01) # Ensure mtime differs bad_file.write_text("syntax error here !!!") # Next load - should warn again (file changed) await provider._list_tools() captured = capsys.readouterr() # Check for warning indicator (rich may truncate long paths) assert "WARNING" in captured.err and "Failed to import" in captured.err async def test_warning_cleared_when_fixed(self, tmp_path: Path, capsys): """Warnings should clear when a file is fixed, and reappear if broken again.""" bad_file = tmp_path / "tool.py" bad_file.write_text("1/0 # broken") provider = FileSystemProvider(tmp_path, reload=True) # First load - should warn captured = capsys.readouterr() # Check for warning indicator (rich may truncate long paths) assert "WARNING" in captured.err and "Failed to import" in captured.err # Fix the file time.sleep(0.01) bad_file.write_text( """\ from fastmcp.tools import tool @tool def my_tool() -> str: return "fixed" """ ) # Load again - should NOT warn, file is fixed await provider._list_tools() captured = capsys.readouterr() assert "Failed to import" not in captured.err assert len(provider._components) == 1 # Break it again time.sleep(0.01) bad_file.write_text("1/0 # broken again") # Should warn again await provider._list_tools() captured = capsys.readouterr() # Check for warning indicator (rich may truncate long paths) assert "WARNING" in captured.err and "Failed to import" in captured.err class TestFileSystemProviderReloadRace: """Test that concurrent readers don't see empty components during reload.""" async def test_concurrent_reader_never_sees_empty(self, tmp_path: Path): """A reader during reload should see either old or new components, never empty.""" (tmp_path / "tool.py").write_text( """\ from fastmcp.tools import tool @tool def my_tool() -> str: return "hello" """ ) provider = FileSystemProvider(tmp_path, reload=True) assert len(await provider._list_tools()) == 1 observed_empty = False async def reader(): nonlocal observed_empty for _ in range(20): tools = await provider._list_tools() if len(tools) == 0: observed_empty = True await asyncio.sleep(0) async def reloader(): for _ in range(5): provider._loaded = False await provider._list_tools() # triggers reload await asyncio.sleep(0) await asyncio.gather(reader(), reloader()) assert not observed_empty, "Reader saw empty components during reload" class TestFileSystemProviderIntegration: """Integration tests with FastMCP server.""" async def test_provider_with_fastmcp_server(self, tmp_path: Path): """FileSystemProvider should work with FastMCP server.""" (tmp_path / "greet.py").write_text( """\ from fastmcp.tools import tool @tool def greet(name: str) -> str: '''Greet someone.''' return f"Hello, {name}!" """ ) provider = FileSystemProvider(tmp_path) mcp = FastMCP("TestServer", providers=[provider]) async with Client(mcp) as client: # List tools tools = await client.list_tools() assert len(tools) == 1 assert tools[0].name == "greet" # Call tool result = await client.call_tool("greet", {"name": "World"}) assert "Hello, World!" in str(result) async def test_provider_with_resources(self, tmp_path: Path): """FileSystemProvider should work with resources.""" (tmp_path / "config.py").write_text( """\ from fastmcp.resources import resource @resource("config://app") def get_config() -> str: '''Get app config.''' return '{"version": "1.0"}' """ ) provider = FileSystemProvider(tmp_path) mcp = FastMCP("TestServer", providers=[provider]) async with Client(mcp) as client: # List resources resources = await client.list_resources() assert len(resources) == 1 assert str(resources[0].uri) == "config://app" # Read resource result = await client.read_resource("config://app") assert "1.0" in str(result) async def test_provider_with_resource_templates(self, tmp_path: Path): """FileSystemProvider should work with resource templates.""" (tmp_path / "users.py").write_text( """\ from fastmcp.resources import resource @resource("users://{user_id}/profile") def get_profile(user_id: str) -> str: '''Get user profile.''' return f'{{"id": "{user_id}", "name": "User {user_id}"}}' """ ) provider = FileSystemProvider(tmp_path) mcp = FastMCP("TestServer", providers=[provider]) async with Client(mcp) as client: # List templates templates = await client.list_resource_templates() assert len(templates) == 1 # Read with parameter result = await client.read_resource("users://123/profile") assert "123" in str(result) async def test_provider_with_prompts(self, tmp_path: Path): """FileSystemProvider should work with prompts.""" (tmp_path / "analyze.py").write_text( """\ from fastmcp.prompts import prompt @prompt def analyze(topic: str) -> str: '''Analyze a topic.''' return f"Please analyze: {topic}" """ ) provider = FileSystemProvider(tmp_path) mcp = FastMCP("TestServer", providers=[provider]) async with Client(mcp) as client: # List prompts prompts = await client.list_prompts() assert len(prompts) == 1 assert prompts[0].name == "analyze" # Get prompt result = await client.get_prompt("analyze", {"topic": "Python"}) assert "Python" in str(result) async def test_nested_directory_structure(self, tmp_path: Path): """FileSystemProvider should work with nested directories.""" # Create nested structure tools = tmp_path / "tools" tools.mkdir() (tools / "greet.py").write_text( """\ from fastmcp.tools import tool @tool def greet(name: str) -> str: return f"Hello, {name}!" """ ) payments = tools / "payments" payments.mkdir() (payments / "charge.py").write_text( """\ from fastmcp.tools import tool @tool def charge(amount: float) -> str: return f"Charged ${amount}" """ ) provider = FileSystemProvider(tmp_path) mcp = FastMCP("TestServer", providers=[provider]) async with Client(mcp) as client: tools_list = await client.list_tools() assert len(tools_list) == 2 names = {t.name for t in tools_list} assert names == {"greet", "charge"} async def test_mounted_servers_with_same_package_and_module_name( self, tmp_path: Path ): """Two providers sharing a package/module path should not collide. Reproduces the exact scenario from the bug report: both providers have a `components/tools.py` package. Without isolation the second provider reuses the first's cached module and `list_tools` returns the first server's tool twice. """ for server_name, tool_name in [ ("server1", "server1_tool"), ("server2", "server2_tool"), ]: components = tmp_path / server_name / "components" components.mkdir(parents=True) (components / "__init__.py").write_text("") (components / "tools.py").write_text( f"""\ from fastmcp.tools import tool @tool def {tool_name}() -> str: return "{tool_name}" """ ) server1 = FastMCP( "server1", providers=[FileSystemProvider(tmp_path / "server1" / "components")], ) server2 = FastMCP( "server2", providers=[FileSystemProvider(tmp_path / "server2" / "components")], ) parent = FastMCP("parent") parent.mount(server1) parent.mount(server2) tools = await parent.list_tools() assert {t.name for t in tools} == {"server1_tool", "server2_tool"} result = await parent.call_tool("server2_tool", {}) assert "server2_tool" in str(result) async def test_mounted_servers_with_same_package_different_module( self, tmp_path: Path ): """Same package name but different module filenames must coexist. The package directory name (`components`) collides while the leaf module differs (`a.py` vs `b.py`). The shared package must not resolve to the first provider's directory, which would drop the second provider's tool. A relative import inside each module exercises that the isolated tree is a real package. """ for server_name, module_name, tool_name in [ ("server1", "a", "server1_tool"), ("server2", "b", "server2_tool"), ]: components = tmp_path / server_name / "components" components.mkdir(parents=True) (components / "__init__.py").write_text("") (components / "_shared.py").write_text(f'LABEL = "{tool_name}"\n') (components / f"{module_name}.py").write_text( f"""\ from fastmcp.tools import tool from ._shared import LABEL @tool def {tool_name}() -> str: return LABEL """ ) server1 = FastMCP( "server1", providers=[FileSystemProvider(tmp_path / "server1" / "components")], ) server2 = FastMCP( "server2", providers=[FileSystemProvider(tmp_path / "server2" / "components")], ) parent = FastMCP("parent") parent.mount(server1) parent.mount(server2) tools = await parent.list_tools() assert {t.name for t in tools} == {"server1_tool", "server2_tool"} result = await parent.call_tool("server2_tool", {}) assert "server2_tool" in str(result) 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"]