import inspect import json import subprocess import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import ValidationError from fastmcp.cli.cli import inspector, run from fastmcp.cli.run import ( create_mcp_config_server, is_url, run_module_command, ) from fastmcp.client.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.mcp_config import MCPConfig, StdioMCPServer from fastmcp.server.server import FastMCP from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestUrlDetection: """Test URL detection functionality.""" def test_is_url_valid_http(self): """Test detection of valid HTTP URLs.""" assert is_url("http://example.com") assert is_url("http://localhost:8080") assert is_url("http://127.0.0.1:3000/path") def test_is_url_valid_https(self): """Test detection of valid HTTPS URLs.""" assert is_url("https://example.com") assert is_url("https://api.example.com/mcp") assert is_url("https://localhost:8443") def test_is_url_invalid(self): """Test detection of non-URLs.""" assert not is_url("server.py") assert not is_url("/path/to/server.py") assert not is_url("server.py:app") assert not is_url("ftp://example.com") # Not http/https assert not is_url("file:///path/to/file") class TestFileSystemSource: """Test FileSystemSource path parsing functionality.""" def test_parse_simple_path(self, tmp_path): """Test parsing simple file path without object.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") source = FileSystemSource(path=str(test_file)) assert Path(source.path).resolve() == test_file.resolve() assert source.entrypoint is None def test_parse_path_with_object(self, tmp_path): """Test parsing file path with object specification.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") source = FileSystemSource(path=f"{test_file}:app") assert Path(source.path).resolve() == test_file.resolve() assert source.entrypoint == "app" def test_parse_complex_object(self, tmp_path): """Test parsing file path with complex object specification.""" test_file = tmp_path / "server.py" test_file.write_text("# test server") # The implementation splits on the last colon, so file:module:app # becomes file_path="file:module" and entrypoint="app" # We need to create a file with a colon in the name for this test complex_file = tmp_path / "server:module.py" complex_file.write_text("# test server") source = FileSystemSource(path=f"{complex_file}:app") assert Path(source.path).resolve() == complex_file.resolve() assert source.entrypoint == "app" async def test_load_server_nonexistent(self): """Test loading nonexistent file path exits.""" source = FileSystemSource(path="nonexistent.py") with pytest.raises(SystemExit) as exc_info: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 async def test_load_server_directory(self, tmp_path): """Test loading directory path exits.""" source = FileSystemSource(path=str(tmp_path)) with pytest.raises(SystemExit) as exc_info: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 class TestMCPConfig: """Test MCPConfig functionality.""" async def test_run_mcp_config(self, tmp_path: Path): """Test creating a server from an MCPConfig file.""" server_script = inspect.cleandoc(""" from fastmcp import FastMCP mcp = FastMCP() @mcp.tool def add(a: int, b: int) -> int: return a + b if __name__ == '__main__': mcp.run() """) script_path: Path = tmp_path / "test.py" script_path.write_text(server_script) mcp_config_path = tmp_path / "mcp_config.json" mcp_config = MCPConfig( mcpServers={ "test_server": StdioMCPServer(command="python", args=[str(script_path)]) } ) mcp_config.write_to_file(mcp_config_path) server: FastMCP[None] = create_mcp_config_server(mcp_config_path) client = Client[FastMCPTransport](server) async with client: tools = await client.list_tools() assert len(tools) == 1 async def test_validate_mcp_config(self, tmp_path: Path): """Test creating a server from an MCPConfig file.""" mcp_config_path = tmp_path / "mcp_config.json" mcp_config = {"mcpServers": {"test_server": dict(x=1, y=2)}} with mcp_config_path.open("w") as f: json.dump(mcp_config, f) with pytest.raises(ValidationError, match="validation errors for MCPConfig"): create_mcp_config_server(mcp_config_path) class TestServerImport: """Test server import functionality using real files.""" async def test_import_server_basic_mcp(self, tmp_path): """Test importing server with basic FastMCP server.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" """) source = FileSystemSource(path=str(test_file)) server = await source.load_server() assert server.name == "TestServer" tools = await server.list_tools() assert any(t.name == "greet" for t in tools) async def test_import_server_with_main_block(self, tmp_path): """Test importing server with if __name__ == '__main__' block.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp app = fastmcp.FastMCP("MainServer") @app.tool def calculate(x: int, y: int) -> int: return x + y if __name__ == "__main__": app.run() """) source = FileSystemSource(path=str(test_file)) server = await source.load_server() assert server.name == "MainServer" tools = await server.list_tools() assert any(t.name == "calculate" for t in tools) async def test_import_server_standard_names(self, tmp_path): """Test automatic detection of standard names (mcp, server, app).""" # Test with 'mcp' name mcp_file = tmp_path / "mcp_server.py" mcp_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("MCPServer") """) source = FileSystemSource(path=str(mcp_file)) server = await source.load_server() assert server.name == "MCPServer" # Test with 'server' name server_file = tmp_path / "server_server.py" server_file.write_text(""" import fastmcp server = fastmcp.FastMCP("ServerServer") """) source = FileSystemSource(path=str(server_file)) server = await source.load_server() assert server.name == "ServerServer" # Test with 'app' name app_file = tmp_path / "app_server.py" app_file.write_text(""" import fastmcp app = fastmcp.FastMCP("AppServer") """) source = FileSystemSource(path=str(app_file)) server = await source.load_server() assert server.name == "AppServer" async def test_import_server_nonstandard_name(self, tmp_path): """Test importing server with non-standard object name.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp my_custom_server = fastmcp.FastMCP("CustomServer") @my_custom_server.tool def custom_tool() -> str: return "custom" """) source = FileSystemSource(path=f"{test_file}:my_custom_server") server = await source.load_server() assert server.name == "CustomServer" tools = await server.list_tools() assert any(t.name == "custom_tool" for t in tools) async def test_import_server_no_standard_names_fails(self, tmp_path): """Test importing server when no standard names exist fails.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp other_name = fastmcp.FastMCP("OtherServer") """) source = FileSystemSource(path=str(test_file)) with pytest.raises(SystemExit) as exc_info: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 async def test_import_server_nonexistent_object_fails(self, tmp_path): """Test importing nonexistent server object fails.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") """) source = FileSystemSource(path=f"{test_file}:nonexistent") with pytest.raises(SystemExit) as exc_info: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 class TestV1ServerAsync: """Test FastMCP 1.x server async support.""" async def test_run_v1_server_stdio(self, tmp_path): """Test that v1 server uses async stdio method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_stdio_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="stdio") run_mock.assert_called_once() async def test_run_v1_server_http(self, tmp_path): """Test that v1 server uses async http method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="http") run_mock.assert_called_once() async def test_run_v1_server_streamable_http(self, tmp_path): """Test that v1 server uses async streamable-http method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="streamable-http") run_mock.assert_called_once() async def test_run_v1_server_sse(self, tmp_path): """Test that v1 server uses async sse method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_sse_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="sse") run_mock.assert_called_once() async def test_run_v1_server_default_transport(self, tmp_path): """Test that v1 server uses streamable-http by default.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file)) run_mock.assert_called_once() async def test_run_v1_server_with_host_port(self, tmp_path): """Test that v1 server receives host/port settings.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command( str(test_file), transport="http", host="0.0.0.0", port=9000 ) run_mock.assert_called_once() class TestSkipSource: """Test the --skip-source functionality.""" async def test_run_command_calls_prepare_by_default(self, tmp_path): """Test that run_command calls source.prepare() by default.""" from unittest.mock import AsyncMock, patch from fastmcp.cli.run import run_command # Create a test server file test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") """) # Create a test config file config_file = tmp_path / "fastmcp.json" config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}} config_file.write_text(json.dumps(config_data)) # Mock the prepare method and server run with ( patch.object( FileSystemSource, "prepare", new_callable=AsyncMock ) as prepare_mock, patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), ): # Run the command await run_command(str(config_file)) # Verify prepare was called prepare_mock.assert_called_once() async def test_run_command_skips_prepare_with_flag(self, tmp_path): """Test that run_command skips source.prepare() when skip_source=True.""" from unittest.mock import AsyncMock, patch from fastmcp.cli.run import run_command # Create a test server file test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") """) # Create a test config file config_file = tmp_path / "fastmcp.json" config_data = {"source": {"path": str(test_file), "entrypoint": "mcp"}} config_file.write_text(json.dumps(config_data)) # Mock the prepare method and server run with ( patch.object( FileSystemSource, "prepare", new_callable=AsyncMock ) as prepare_mock, patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), ): # Run the command with skip_source=True await run_command(str(config_file), skip_source=True) # Verify prepare was NOT called prepare_mock.assert_not_called() async def test_filesystem_source_prepare_by_default(self, tmp_path): """Test that FileSystemSource is prepared when using direct file spec.""" from unittest.mock import AsyncMock, patch from fastmcp.cli.run import run_command from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import ( FileSystemSource, ) # Create a test server file test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") """) # Mock the prepare method and server run with ( patch.object( FileSystemSource, "prepare", new_callable=AsyncMock ) as prepare_mock, patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), ): # Run with direct file specification await run_command(str(test_file)) # Verify prepare was called prepare_mock.assert_called_once() async def test_filesystem_source_skip_prepare_with_flag(self, tmp_path): """Test that FileSystemSource.prepare() is skipped with skip_source flag.""" from unittest.mock import AsyncMock, patch from fastmcp.cli.run import run_command from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import ( FileSystemSource, ) # Create a test server file test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") """) # Mock the prepare method and server run with ( patch.object( FileSystemSource, "prepare", new_callable=AsyncMock ) as prepare_mock, patch("fastmcp.server.server.FastMCP.run_async", new_callable=AsyncMock), ): # Run with direct file specification and skip_source=True await run_command(str(test_file), skip_source=True) # Verify prepare was NOT called prepare_mock.assert_not_called() class TestReloadFunctionality: """Test reload functionality.""" 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 _watch_filter # 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_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 _watch_filter 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" class TestRunModuleCommand: """Test run_module_command functionality.""" def test_runs_python_m_module(self): """Test that run_module_command invokes python -m .""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit) as exc_info, ): run_module_command("my_package") assert exc_info.value.code == 0 call_args = mock_run.call_args cmd = call_args[0][0] assert "-m" in cmd assert "my_package" in cmd def test_forwards_extra_args(self): """Test that extra arguments are forwarded after the module name.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit), ): run_module_command("my_package", extra_args=["--host", "0.0.0.0"]) cmd = mock_run.call_args[0][0] assert "--host" in cmd assert "0.0.0.0" in cmd def test_uses_env_command_builder(self): """Test that env_command_builder wraps the command.""" mock_result = MagicMock() mock_result.returncode = 0 def fake_builder(cmd: list[str]) -> list[str]: return ["uv", "run", *cmd] with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit), ): run_module_command("my_package", env_command_builder=fake_builder) cmd = mock_run.call_args[0][0] assert cmd[0] == "uv" assert cmd[1] == "run" # Should use bare "python" (not sys.executable) so uv resolves the interpreter assert cmd[2] == "python" assert "-m" in cmd assert "my_package" in cmd def test_exits_with_subprocess_error_code(self): """Test that non-zero exit codes from the module are propagated.""" with ( patch( "fastmcp.cli.run.subprocess.run", side_effect=subprocess.CalledProcessError(42, ["python", "-m", "bad"]), ), pytest.raises(SystemExit) as exc_info, ): run_module_command("bad") assert exc_info.value.code == 42 def test_no_env_builder_runs_plain_python(self): """Test that without env_command_builder, plain python is used.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit), ): run_module_command("my_module", env_command_builder=None) cmd = mock_run.call_args[0][0] assert cmd[0] == sys.executable assert cmd[1] == "-m" assert cmd[2] == "my_module" class TestRunModuleMode: """Test the run command's module-mode branch.""" async def test_run_module_mode_requires_server_spec(self): """Test that module mode exits with error when server_spec is None.""" with pytest.raises(SystemExit) as exc_info: await run(None, module=True) assert exc_info.value.code == 1 async def test_run_module_mode_warns_ignored_options(self, caplog): """Test that ignored options produce a warning in module mode.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch("fastmcp.cli.run.subprocess.run", return_value=mock_result), pytest.raises(SystemExit), caplog.at_level("WARNING"), ): await run( "my_module", module=True, transport="sse", host="0.0.0.0", port=8080, ) assert any("ignored in module mode" in r.message for r in caplog.records) async def test_run_module_mode_delegates_to_run_module_command(self): """Test that module mode calls run_module_command with correct args.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_subprocess, pytest.raises(SystemExit), ): await run("my_module", module=True) cmd = mock_subprocess.call_args[0][0] assert "-m" in cmd assert "my_module" in cmd async def test_run_module_mode_with_reload(self): """Test that --reload in module mode delegates to run_with_reload.""" with patch( "fastmcp.cli.run.run_with_reload", new_callable=AsyncMock ) as mock_reload: await run("my_module", module=True, reload=True, skip_env=True) mock_reload.assert_called_once() cmd = mock_reload.call_args[0][0] assert "fastmcp" in cmd assert "--module" in cmd assert "--no-reload" in cmd assert "my_module" in cmd class TestInspectorModuleMode: """Test the inspector command's module-mode handling.""" async def test_inspector_module_mode_skips_load_server(self): """Test that inspector with module=True skips load_server() and forwards --module.""" mock_config = MagicMock() mock_config.deployment.port = 8080 mock_config.environment.build_command = lambda cmd: cmd mock_config.source.load_server = AsyncMock() mock_process = MagicMock() mock_process.returncode = 0 with ( patch( "fastmcp.cli.cli.load_and_merge_config", return_value=(mock_config, "my_module"), ), patch("fastmcp.cli.cli._get_npx_command", return_value="npx"), patch( "fastmcp.cli.cli.subprocess.run", return_value=mock_process ) as mock_subprocess, pytest.raises(SystemExit), ): await inspector("my_module", module=True) # load_server should NOT have been called in module mode mock_config.source.load_server.assert_not_called() # --module should be in the subprocess command cmd = mock_subprocess.call_args[0][0] assert "--module" in cmd