"""Tests for the CLI module.""" import subprocess from pathlib import Path from unittest.mock import MagicMock, Mock, patch import pytest from typer.testing import CliRunner from fastmcp.cli import cli # Set up test runner runner = CliRunner() @pytest.fixture def mock_console(): """Mock the rich console to test output.""" with patch("fastmcp.cli.cli.console") as mock_console: yield mock_console @pytest.fixture def mock_logger(): """Mock the logger to test logging.""" with patch("fastmcp.cli.cli.logger") as mock_logger: yield mock_logger @pytest.fixture def mock_exit(): """Mock sys.exit to prevent tests from exiting.""" with patch("sys.exit") as mock_exit: yield mock_exit @pytest.fixture def temp_python_file(tmp_path): """Create a temporary Python file with a test server.""" server_code = """ from mcp import Server class TestServer(Server): name = "test_server" dependencies = ["package1", "package2"] def run(self, **kwargs): print("Running server with", kwargs) mcp = TestServer() server = TestServer() app = TestServer() custom_server = TestServer() """ file_path = tmp_path / "test_server.py" file_path.write_text(server_code) return file_path @pytest.fixture def temp_env_file(tmp_path): """Create a temporary .env file.""" env_content = """ TEST_VAR1=value1 TEST_VAR2=value2 """ env_path = tmp_path / ".env" env_path.write_text(env_content) return env_path class TestHelperFunctions: """Tests for helper functions in cli.py.""" def test_get_npx_command_unix(self): """Test getting npx command on unix systems.""" with patch("sys.platform", "linux"): with patch("subprocess.run") as mock_run: mock_run.return_value = Mock(returncode=0) assert cli._get_npx_command() == "npx" def test_get_npx_command_windows(self): """Test getting npx command on Windows.""" with patch("sys.platform", "win32"): with patch("subprocess.run") as mock_run: # First try fails, second succeeds mock_run.side_effect = [ subprocess.CalledProcessError(1, "npx.cmd"), Mock(returncode=0), ] assert cli._get_npx_command() == "npx.exe" def test_get_npx_command_not_found(self): """Test when npx command is not found.""" with patch("sys.platform", "win32"): with patch("subprocess.run") as mock_run: mock_run.side_effect = [ subprocess.CalledProcessError(1, "npx.cmd"), subprocess.CalledProcessError(1, "npx.exe"), subprocess.CalledProcessError(1, "npx"), ] assert cli._get_npx_command() is None def test_parse_env_var_valid(self): """Test parsing valid environment variables.""" assert cli._parse_env_var("KEY=VALUE") == ("KEY", "VALUE") assert cli._parse_env_var("KEY=") == ("KEY", "") assert cli._parse_env_var("KEY=VALUE=WITH=EQUALS") == ( "KEY", "VALUE=WITH=EQUALS", ) assert cli._parse_env_var(" KEY = VALUE ") == ("KEY", "VALUE") def test_build_uv_command_basic(self): """Test building basic uv command.""" cmd = cli._build_uv_command("file.py") assert cmd == ["uv", "run", "--with", "fastmcp", "fastmcp", "run", "file.py"] def test_build_uv_command_with_editable(self): """Test building uv command with editable flag.""" project_path = Path("/path/to/project") cmd = cli._build_uv_command("file.py", with_editable=project_path) assert cmd == [ "uv", "run", "--with", "fastmcp", "--with-editable", str(project_path), "fastmcp", "run", "file.py", ] def test_build_uv_command_with_packages(self): """Test building uv command with additional packages.""" cmd = cli._build_uv_command("file.py", with_packages=["pkg1", "pkg2"]) assert cmd == [ "uv", "run", "--with", "fastmcp", "--with", "pkg1", "--with", "pkg2", "fastmcp", "run", "file.py", ] def test_build_uv_command_full(self): """Test building full uv command with all options.""" project_path = Path("/path/to/project") cmd = cli._build_uv_command( "file.py:server", with_editable=project_path, with_packages=["pkg1", "pkg2"], ) assert cmd == [ "uv", "run", "--with", "fastmcp", "--with-editable", str(project_path), "--with", "pkg1", "--with", "pkg2", "fastmcp", "run", "file.py:server", ] class TestVersionCommand: """Tests for the version command.""" def test_version_early_exit_with_resilient_parsing(self): """Test version command exits early with resilient parsing.""" ctx = MagicMock() ctx.resilient_parsing = True result = cli.version(ctx) assert result is None class TestDevCommand: """Tests for the dev command.""" def test_dev_command_success(self, temp_python_file, mock_logger): """Test successful dev command execution.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() mock_server.dependencies = ["extra_dep"] mock_import.return_value = mock_server mock_get_npx.return_value = "npx" mock_build_uv.return_value = ["uv", "command"] mock_run.return_value = MagicMock(returncode=0) result = runner.invoke(cli.app, ["dev", str(temp_python_file)]) assert result.exit_code == 0 mock_run.assert_called_once() # Check dependencies were passed correctly mock_build_uv.assert_called_once_with( str(temp_python_file), None, ["extra_dep"] ) def test_dev_command_with_ui_port(self, temp_python_file): """Test dev command with UI port.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, ): mock_parse.return_value = (temp_python_file, None) mock_import.return_value = MagicMock(dependencies=[]) mock_get_npx.return_value = "npx" mock_build_uv.return_value = ["uv", "command"] mock_run.return_value = MagicMock(returncode=0) result = runner.invoke( cli.app, ["dev", str(temp_python_file), "--ui-port", "3000"] ) assert result.exit_code == 0 # Check environment variables were set env = mock_run.call_args[1]["env"] assert "CLIENT_PORT" in env assert env["CLIENT_PORT"] == "3000" def test_dev_command_with_server_port(self, temp_python_file): """Test dev command with server port.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, ): mock_parse.return_value = (temp_python_file, None) mock_import.return_value = MagicMock(dependencies=[]) mock_get_npx.return_value = "npx" mock_build_uv.return_value = ["uv", "command"] mock_run.return_value = MagicMock(returncode=0) result = runner.invoke( cli.app, ["dev", str(temp_python_file), "--server-port", "8080"] ) assert result.exit_code == 0 # Check environment variables were set env = mock_run.call_args[1]["env"] assert "SERVER_PORT" in env assert env["SERVER_PORT"] == "8080" def test_dev_command_inspector_version(self, temp_python_file): """Test dev command with specific inspector version.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, ): mock_parse.return_value = (temp_python_file, None) mock_import.return_value = MagicMock(dependencies=[]) mock_get_npx.return_value = "npx" mock_build_uv.return_value = ["uv", "command"] mock_run.return_value = MagicMock(returncode=0) result = runner.invoke( cli.app, ["dev", str(temp_python_file), "--inspector-version", "1.0.0"] ) assert result.exit_code == 0 # Check inspector version was used inspector_cmd = mock_run.call_args[0][0][1] assert inspector_cmd == "@modelcontextprotocol/inspector@1.0.0" class TestRunCommand: """Tests for the run command.""" def test_run_command_success(self, temp_python_file): """Test successful run command execution.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.run.logger") as mock_logger, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() mock_server.name = "test_server" mock_import.return_value = mock_server result = runner.invoke(cli.app, ["run", str(temp_python_file)]) assert result.exit_code == 0 mock_server.run.assert_called_once_with() mock_logger.debug.assert_called_with( f'Found server "test_server" in {temp_python_file}' ) def test_run_command_with_transport(self, temp_python_file): """Test run command with transport option.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() mock_server.name = "test_server" mock_import.return_value = mock_server result = runner.invoke( cli.app, ["run", str(temp_python_file), "--transport", "sse"] ) assert result.exit_code == 0 mock_server.run.assert_called_once_with(transport="sse") def test_run_command_with_host(self, temp_python_file): """Test run command with host option.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() mock_server.name = "test_server" mock_import.return_value = mock_server result = runner.invoke( cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"] ) assert result.exit_code == 0 mock_server.run.assert_called_once_with(host="0.0.0.0") def test_run_command_with_port(self, temp_python_file): """Test run command with port option.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() mock_server.name = "test_server" mock_import.return_value = mock_server result = runner.invoke( cli.app, ["run", str(temp_python_file), "--port", "8080"] ) assert result.exit_code == 0 mock_server.run.assert_called_once_with(port=8080) def test_run_command_with_log_level(self, temp_python_file): """Test run command with log level option.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() mock_server.name = "test_server" mock_import.return_value = mock_server result = runner.invoke( cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"] ) assert result.exit_code == 0 mock_server.run.assert_called_once_with(log_level="DEBUG") def test_run_command_with_multiple_options(self, temp_python_file): """Test run command with multiple options.""" with ( patch("fastmcp.cli.run.parse_file_path") as mock_parse, patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() mock_server.name = "test_server" mock_import.return_value = mock_server result = runner.invoke( cli.app, [ "run", str(temp_python_file), "--transport", "sse", "--host", "0.0.0.0", "--port", "8080", "--log-level", "DEBUG", ], ) assert result.exit_code == 0 mock_server.run.assert_called_once_with( transport="sse", host="0.0.0.0", port=8080, log_level="DEBUG" )