fastmcp/tests/fs/test_provider.py
Marvin Context Protocol 27dea0e3b6 Fix FileSystemProvider reload race condition, redundant reloads, and missing warning
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
🤖 Generated with Claude Code
2026-03-27 19:25:42 +00:00

650 lines
20 KiB
Python

"""Tests for FileSystemProvider."""
import asyncio
import time
from pathlib import Path
from unittest.mock import patch
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 _ensure_loaded
(tmp_path / "tool2.py").write_text(
"""\
from fastmcp.tools import tool
@tool
def added() -> str:
return "added"
"""
)
# With reload=True, _ensure_loaded re-scans
await provider._ensure_loaded()
assert len(provider._components) == 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._ensure_loaded()
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._ensure_loaded()
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._ensure_loaded()
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._ensure_loaded()
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 TestFileSystemProviderAtomicReload:
"""Tests for atomic component swap during reload (bug #4)."""
async def test_reload_swaps_components_atomically(self, tmp_path: Path):
"""Components dict should never be empty during a reload."""
(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(provider._components) == 1
# Reload should produce a new dict, not clear the existing one
original_dict = provider._components
await provider._ensure_loaded()
# After reload, _components is a different dict object
assert provider._components is not original_dict
assert len(provider._components) == 1
async def test_reload_does_not_expose_empty_components(self, tmp_path: Path):
"""Concurrent readers should never see an empty components dict."""
(tmp_path / "tool.py").write_text(
"""\
from fastmcp.tools import tool
@tool
def my_tool() -> str:
return "hello"
"""
)
provider = FileSystemProvider(tmp_path, reload=True)
observed_empty = False
original_load = provider._load_components
def patched_load(self: FileSystemProvider) -> None:
nonlocal observed_empty
original_load()
if len(self._components) == 0:
observed_empty = True
with patch.object(type(provider), "_load_components", patched_load):
await provider._ensure_loaded()
assert not observed_empty
class TestFileSystemProviderReloadDedup:
"""Tests for reload deduplication via generation counter (bug #5)."""
async def test_generation_counter_increments(self, tmp_path: Path):
"""Generation counter should increment after each reload."""
(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 provider._reload_generation == 0
await provider._ensure_loaded()
assert provider._reload_generation == 1
await provider._ensure_loaded()
assert provider._reload_generation == 2
async def test_concurrent_reloads_deduplicated(self, tmp_path: Path):
"""Concurrent _ensure_loaded calls should not each trigger a reload."""
(tmp_path / "tool.py").write_text(
"""\
from fastmcp.tools import tool
@tool
def my_tool() -> str:
return "hello"
"""
)
provider = FileSystemProvider(tmp_path, reload=True)
load_count = 0
original_load = FileSystemProvider._load_components
def counting_load(self: FileSystemProvider) -> None:
nonlocal load_count
load_count += 1
original_load(self)
with patch.object(type(provider), "_load_components", counting_load):
# Launch multiple concurrent _ensure_loaded calls
await asyncio.gather(
provider._ensure_loaded(),
provider._ensure_loaded(),
provider._ensure_loaded(),
)
# Only one reload should have happened (they all read generation 0
# before any acquired the lock, and the first one increments it)
assert load_count == 1
class TestFileSystemProviderNonExistentRoot:
"""Tests for warning when root directory does not exist."""
def test_warning_on_nonexistent_root(self, tmp_path: Path, capsys):
"""A non-existent root should log a warning."""
nonexistent = tmp_path / "does_not_exist"
provider = FileSystemProvider(nonexistent)
captured = capsys.readouterr()
assert "WARNING" in captured.err and "root does not" in captured.err
assert len(provider._components) == 0
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"}
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"]