diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index ece664b72..64fbf0d06 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -25,6 +25,57 @@ logger = get_logger("cli.run") TransportType = Literal["stdio", "http", "sse", "streamable-http"] LogLevelType = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +# File extensions to watch for reload +WATCHED_EXTENSIONS: set[str] = { + # Python + ".py", + # JavaScript/TypeScript + ".js", + ".ts", + ".jsx", + ".tsx", + # Markup/Content + ".html", + ".md", + ".mdx", + ".txt", + ".xml", + # Styles + ".css", + ".scss", + ".sass", + ".less", + # Data/Config + ".json", + ".yaml", + ".yml", + ".toml", + # Framework-specific + ".vue", + ".svelte", + # GraphQL + ".graphql", + ".gql", + # Images + ".svg", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".webp", + # Media + ".mp3", + ".mp4", + ".wav", + ".webm", + # Fonts + ".woff", + ".woff2", + ".ttf", + ".eot", +} + def is_url(path: str) -> bool: """Check if a string is a URL.""" @@ -231,9 +282,9 @@ async def run_v1_server_async( await server.run_sse_async() -def _python_file_filter(change: Change, path: str) -> bool: - """Filter for Python files only.""" - return path.endswith(".py") +def _watch_filter(_change: Change, path: str) -> bool: + """Filter for files that should trigger reload.""" + return any(path.endswith(ext) for ext in WATCHED_EXTENSIONS) async def _terminate_process(process: asyncio.subprocess.Process) -> None: @@ -300,7 +351,7 @@ async def run_with_reload( # Watch for either: file changes OR process death watch_task = asyncio.create_task( - anext(aiter(awatch(*watch_paths, watch_filter=_python_file_filter))) + anext(aiter(awatch(*watch_paths, watch_filter=_watch_filter))) ) wait_task = asyncio.create_task(process.wait()) shutdown_task = asyncio.create_task(shutdown_event.wait()) @@ -331,7 +382,7 @@ async def run_with_reload( # Wait for file change or shutdown (avoid hot loop on crash) watch_task = asyncio.create_task( - anext(aiter(awatch(*watch_paths, watch_filter=_python_file_filter))) + anext(aiter(awatch(*watch_paths, watch_filter=_watch_filter))) ) shutdown_task = asyncio.create_task(shutdown_event.wait()) done, pending = await asyncio.wait( diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 90582b684..184ea1dde 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -605,24 +605,93 @@ mcp = fastmcp.FastMCP("TestServer") class TestReloadFunctionality: """Test reload functionality.""" - def test_python_file_filter_accepts_py_files(self): - """Test that Python file filter accepts .py files.""" + def test_watch_filter_accepts_watched_extensions(self): + """Test that watch filter accepts common source file extensions.""" from watchfiles import Change - from fastmcp.cli.run import _python_file_filter + from fastmcp.cli.run import _watch_filter - assert _python_file_filter(Change.modified, "/path/to/file.py") is True - assert _python_file_filter(Change.added, "server.py") is True - assert _python_file_filter(Change.deleted, "/some/dir/module.py") is True + # Python + assert _watch_filter(Change.modified, "/path/to/file.py") is True + assert _watch_filter(Change.added, "server.py") is True + # JavaScript/TypeScript + assert _watch_filter(Change.modified, "/path/to/file.js") is True + assert _watch_filter(Change.modified, "/path/to/file.ts") is True + assert _watch_filter(Change.modified, "/path/to/file.jsx") is True + assert _watch_filter(Change.modified, "/path/to/file.tsx") is True + # Markup/Content + assert _watch_filter(Change.modified, "/path/to/file.html") is True + assert _watch_filter(Change.modified, "/path/to/file.md") is True + assert _watch_filter(Change.modified, "/path/to/file.txt") is True + # Styles + assert _watch_filter(Change.modified, "/path/to/file.css") is True + assert _watch_filter(Change.modified, "/path/to/file.scss") is True + # Data/Config + assert _watch_filter(Change.modified, "/path/to/file.json") is True + assert _watch_filter(Change.modified, "/path/to/file.yaml") is True + # Images + assert _watch_filter(Change.modified, "/path/to/file.png") is True + assert _watch_filter(Change.modified, "/path/to/file.svg") is True - def test_python_file_filter_rejects_non_py_files(self): - """Test that Python file filter rejects non-.py files.""" + def test_watch_filter_rejects_unwatched_extensions(self): + """Test that watch filter rejects files not in the watched set.""" from watchfiles import Change - from fastmcp.cli.run import _python_file_filter + from fastmcp.cli.run import _watch_filter - assert _python_file_filter(Change.modified, "/path/to/file.txt") is False - assert _python_file_filter(Change.modified, "/path/to/file.js") is False - assert _python_file_filter(Change.modified, "/path/to/file.pyc") is False - assert _python_file_filter(Change.modified, "/path/to/.py") is True # Edge case - assert _python_file_filter(Change.modified, "Dockerfile") is False + assert _watch_filter(Change.modified, "/path/to/file.pyc") is False + assert _watch_filter(Change.modified, "/path/to/file.pyo") is False + assert _watch_filter(Change.modified, "Dockerfile") is False + assert _watch_filter(Change.modified, "/path/to/file.lock") is False + assert _watch_filter(Change.modified, "/path/to/.gitignore") is False + + def test_all_watched_extensions_are_accepted(self): + """Test that every extension in WATCHED_EXTENSIONS is accepted.""" + from watchfiles import Change + + from fastmcp.cli.run import WATCHED_EXTENSIONS, _watch_filter + + for ext in WATCHED_EXTENSIONS: + path = f"/path/to/file{ext}" + assert _watch_filter(Change.modified, path) is True, ( + f"Expected {ext} to be watched" + ) + + def test_watched_extensions_includes_frontend_types(self): + """Verify WATCHED_EXTENSIONS contains the expected frontend file types.""" + from fastmcp.cli.run import WATCHED_EXTENSIONS + + # Core frontend extensions that must be present + expected = { + # Python + ".py", + # JavaScript/TypeScript + ".js", + ".ts", + ".jsx", + ".tsx", + # Markup + ".html", + ".md", + ".mdx", + ".xml", + # Styles + ".css", + ".scss", + ".sass", + ".less", + # Data/Config + ".json", + ".yaml", + ".yml", + ".toml", + # Images + ".png", + ".jpg", + ".svg", + # Media + ".mp4", + ".mp3", + } + for ext in expected: + assert ext in WATCHED_EXTENSIONS, f"Expected {ext} in WATCHED_EXTENSIONS"