diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index cf72ef64e..2aaab1e3d 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1,5 +1,6 @@ """Tests for the main CLI functionality.""" +import subprocess from pathlib import Path from unittest.mock import Mock, patch @@ -197,6 +198,114 @@ class TestRunCommand: assert exc_info.value.code == 1 +class TestWindowsSpecific: + """Test Windows-specific functionality.""" + + @patch("subprocess.run") + def test_get_npx_command_windows_cmd(self, mock_run): + """Test npx command detection on Windows with npx.cmd.""" + from fastmcp.cli.cli import _get_npx_command + + with patch("sys.platform", "win32"): + # First call succeeds with npx.cmd + mock_run.return_value = Mock(returncode=0) + + result = _get_npx_command() + + assert result == "npx.cmd" + mock_run.assert_called_once_with( + ["npx.cmd", "--version"], + check=True, + capture_output=True, + shell=True, + ) + + @patch("subprocess.run") + def test_get_npx_command_windows_exe(self, mock_run): + """Test npx command detection on Windows with npx.exe.""" + from fastmcp.cli.cli import _get_npx_command + + with patch("sys.platform", "win32"): + # First call fails, second succeeds + mock_run.side_effect = [ + subprocess.CalledProcessError(1, "npx.cmd"), + Mock(returncode=0), + ] + + result = _get_npx_command() + + assert result == "npx.exe" + assert mock_run.call_count == 2 + + @patch("subprocess.run") + def test_get_npx_command_windows_fallback(self, mock_run): + """Test npx command detection on Windows with plain npx.""" + from fastmcp.cli.cli import _get_npx_command + + with patch("sys.platform", "win32"): + # First two calls fail, third succeeds + mock_run.side_effect = [ + subprocess.CalledProcessError(1, "npx.cmd"), + subprocess.CalledProcessError(1, "npx.exe"), + Mock(returncode=0), + ] + + result = _get_npx_command() + + assert result == "npx" + assert mock_run.call_count == 3 + + @patch("subprocess.run") + def test_get_npx_command_windows_not_found(self, mock_run): + """Test npx command detection on Windows when npx is not found.""" + from fastmcp.cli.cli import _get_npx_command + + with patch("sys.platform", "win32"): + # All calls fail + mock_run.side_effect = subprocess.CalledProcessError(1, "npx") + + result = _get_npx_command() + + assert result is None + assert mock_run.call_count == 3 + + @patch("subprocess.run") + def test_get_npx_command_unix(self, mock_run): + """Test npx command detection on Unix systems.""" + from fastmcp.cli.cli import _get_npx_command + + with patch("sys.platform", "darwin"): + result = _get_npx_command() + + assert result == "npx" + mock_run.assert_not_called() + + def test_windows_path_parsing_with_colon(self): + """Test parsing Windows paths with drive letters and colons.""" + from fastmcp.cli.run import parse_file_path + + # We can't test actual Windows paths on non-Windows systems, + # but we can test the logic with mock paths + with patch("pathlib.Path.exists") as mock_exists: + with patch("pathlib.Path.is_file") as mock_is_file: + mock_exists.return_value = True + mock_is_file.return_value = True + + # Test that C:\path\file.py is parsed correctly + with patch("pathlib.Path.resolve") as mock_resolve: + mock_resolve.return_value = Path("C:/path/file.py") + + file_path, obj = parse_file_path("C:\\path\\file.py") + assert obj is None + + # Test C:\path\file.py:object parsing + with patch("pathlib.Path.resolve") as mock_resolve: + mock_resolve.return_value = Path("C:/path/file.py") + + file_path, obj = parse_file_path("C:\\path\\file.py:myapp") + assert obj == "myapp" + + class TestInspectCommand: """Test the inspect command.""" diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py new file mode 100644 index 000000000..739611d7e --- /dev/null +++ b/tests/cli/test_cursor.py @@ -0,0 +1,349 @@ +"""Tests for Cursor integration functionality.""" + +import base64 +import json +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from fastmcp.cli.install.cursor import ( + cursor_command, + generate_cursor_deeplink, + install_cursor, + open_deeplink, +) +from fastmcp.mcp_config import StdioMCPServer + + +class TestCursorDeeplinkGeneration: + """Test cursor deeplink generation functionality.""" + + def test_generate_deeplink_basic(self): + """Test basic deeplink generation.""" + server_config = StdioMCPServer( + command="uv", + args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"], + ) + + deeplink = generate_cursor_deeplink("test-server", server_config) + + assert deeplink.startswith("cursor://anysphere.cursor-deeplink/mcp/install?") + assert "name=test-server" in deeplink + assert "config=" in deeplink + + # Verify base64 encoding + config_part = deeplink.split("config=")[1] + decoded = base64.urlsafe_b64decode(config_part).decode() + config_data = json.loads(decoded) + + assert config_data["command"] == "uv" + assert config_data["args"] == [ + "run", + "--with", + "fastmcp", + "fastmcp", + "run", + "server.py", + ] + + def test_generate_deeplink_with_env_vars(self): + """Test deeplink generation with environment variables.""" + server_config = StdioMCPServer( + command="uv", + args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"], + env={"API_KEY": "secret123", "DEBUG": "true"}, + ) + + deeplink = generate_cursor_deeplink("my-server", server_config) + + # Decode and verify + config_part = deeplink.split("config=")[1] + decoded = base64.urlsafe_b64decode(config_part).decode() + config_data = json.loads(decoded) + + assert config_data["env"] == {"API_KEY": "secret123", "DEBUG": "true"} + + def test_generate_deeplink_special_characters(self): + """Test deeplink generation with special characters in server name.""" + server_config = StdioMCPServer( + command="uv", + args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"], + ) + + # Test with spaces and special chars in name + deeplink = generate_cursor_deeplink("my server (test)", server_config) + + assert ( + "name=my%20server%20%28test%29" in deeplink + or "name=my server (test)" in deeplink + ) + + def test_generate_deeplink_empty_config(self): + """Test deeplink generation with minimal config.""" + server_config = StdioMCPServer(command="python", args=["server.py"]) + + deeplink = generate_cursor_deeplink("minimal", server_config) + + config_part = deeplink.split("config=")[1] + decoded = base64.urlsafe_b64decode(config_part).decode() + config_data = json.loads(decoded) + + assert config_data["command"] == "python" + assert config_data["args"] == ["server.py"] + assert config_data["env"] == {} # Empty env dict is included + + def test_generate_deeplink_complex_args(self): + """Test deeplink generation with complex arguments.""" + server_config = StdioMCPServer( + command="uv", + args=[ + "run", + "--with", + "fastmcp", + "--with", + "numpy>=1.20", + "--with-editable", + "/path/to/local/package", + "fastmcp", + "run", + "server.py:CustomServer", + ], + ) + + deeplink = generate_cursor_deeplink("complex-server", server_config) + + config_part = deeplink.split("config=")[1] + decoded = base64.urlsafe_b64decode(config_part).decode() + config_data = json.loads(decoded) + + assert "--with-editable" in config_data["args"] + assert "server.py:CustomServer" in config_data["args"] + + +class TestOpenDeeplink: + """Test deeplink opening functionality.""" + + @patch("subprocess.run") + def test_open_deeplink_macos(self, mock_run): + """Test opening deeplink on macOS.""" + with patch("sys.platform", "darwin"): + mock_run.return_value = Mock(returncode=0) + + result = open_deeplink("cursor://test") + + assert result is True + mock_run.assert_called_once_with( + ["open", "cursor://test"], check=True, capture_output=True + ) + + @patch("subprocess.run") + def test_open_deeplink_windows(self, mock_run): + """Test opening deeplink on Windows.""" + with patch("sys.platform", "win32"): + mock_run.return_value = Mock(returncode=0) + + result = open_deeplink("cursor://test") + + assert result is True + mock_run.assert_called_once_with( + ["start", "cursor://test"], shell=True, check=True, capture_output=True + ) + + @patch("subprocess.run") + def test_open_deeplink_linux(self, mock_run): + """Test opening deeplink on Linux.""" + with patch("sys.platform", "linux"): + mock_run.return_value = Mock(returncode=0) + + result = open_deeplink("cursor://test") + + assert result is True + mock_run.assert_called_once_with( + ["xdg-open", "cursor://test"], check=True, capture_output=True + ) + + @patch("subprocess.run") + def test_open_deeplink_failure(self, mock_run): + """Test handling of deeplink opening failure.""" + import subprocess + + mock_run.side_effect = subprocess.CalledProcessError(1, ["open"]) + + result = open_deeplink("cursor://test") + + assert result is False + + @patch("subprocess.run") + def test_open_deeplink_command_not_found(self, mock_run): + """Test handling when open command is not found.""" + mock_run.side_effect = FileNotFoundError() + + result = open_deeplink("cursor://test") + + assert result is False + + +class TestInstallCursor: + """Test cursor installation functionality.""" + + @patch("fastmcp.cli.install.cursor.open_deeplink") + @patch("fastmcp.cli.install.cursor.print") + def test_install_cursor_success(self, mock_print, mock_open_deeplink): + """Test successful cursor installation.""" + mock_open_deeplink.return_value = True + + result = install_cursor( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + ) + + assert result is True + mock_open_deeplink.assert_called_once() + # Verify the deeplink was generated correctly + call_args = mock_open_deeplink.call_args[0][0] + assert call_args.startswith("cursor://anysphere.cursor-deeplink/mcp/install?") + assert "name=test-server" in call_args + + @patch("fastmcp.cli.install.cursor.open_deeplink") + @patch("fastmcp.cli.install.cursor.print") + def test_install_cursor_with_packages(self, mock_print, mock_open_deeplink): + """Test cursor installation with additional packages.""" + mock_open_deeplink.return_value = True + + result = install_cursor( + file=Path("/path/to/server.py"), + server_object="app", + name="test-server", + with_packages=["numpy", "pandas"], + env_vars={"API_KEY": "test"}, + ) + + assert result is True + call_args = mock_open_deeplink.call_args[0][0] + + # Decode the config to verify packages + config_part = call_args.split("config=")[1] + decoded = base64.urlsafe_b64decode(config_part).decode() + config_data = json.loads(decoded) + + # Check that all packages are included + assert "--with" in config_data["args"] + assert "numpy" in config_data["args"] + assert "pandas" in config_data["args"] + assert "fastmcp" in config_data["args"] + assert config_data["env"] == {"API_KEY": "test"} + + @patch("fastmcp.cli.install.cursor.open_deeplink") + @patch("fastmcp.cli.install.cursor.print") + def test_install_cursor_with_editable(self, mock_print, mock_open_deeplink): + """Test cursor installation with editable package.""" + mock_open_deeplink.return_value = True + + result = install_cursor( + file=Path("/path/to/server.py"), + server_object="custom_app", + name="test-server", + with_editable=Path("/local/package"), + ) + + assert result is True + call_args = mock_open_deeplink.call_args[0][0] + + # Decode and verify editable path + config_part = call_args.split("config=")[1] + decoded = base64.urlsafe_b64decode(config_part).decode() + config_data = json.loads(decoded) + + assert "--with-editable" in config_data["args"] + assert "/local/package" in config_data["args"] + assert "server.py:custom_app" in " ".join(config_data["args"]) + + @patch("fastmcp.cli.install.cursor.open_deeplink") + @patch("fastmcp.cli.install.cursor.print") + def test_install_cursor_failure(self, mock_print, mock_open_deeplink): + """Test cursor installation when deeplink fails to open.""" + mock_open_deeplink.return_value = False + + result = install_cursor( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + ) + + assert result is False + # Verify failure message was printed + mock_print.assert_called() + + def test_install_cursor_deduplicate_packages(self): + """Test that duplicate packages are deduplicated.""" + with patch("fastmcp.cli.install.cursor.open_deeplink") as mock_open: + mock_open.return_value = True + + install_cursor( + file=Path("/path/to/server.py"), + server_object=None, + name="test-server", + with_packages=["numpy", "fastmcp", "numpy", "pandas", "fastmcp"], + ) + + call_args = mock_open.call_args[0][0] + config_part = call_args.split("config=")[1] + decoded = base64.urlsafe_b64decode(config_part).decode() + config_data = json.loads(decoded) + + # Count occurrences of each package + args_str = " ".join(config_data["args"]) + assert args_str.count("numpy") == 1 + assert args_str.count("pandas") == 1 + # fastmcp appears twice: once as --with fastmcp and once as the command + assert args_str.count("fastmcp") == 2 + + +class TestCursorCommand: + """Test the cursor CLI command.""" + + @patch("fastmcp.cli.install.cursor.install_cursor") + @patch("fastmcp.cli.install.cursor.process_common_args") + def test_cursor_command_basic(self, mock_process_args, mock_install): + """Test basic cursor command execution.""" + mock_process_args.return_value = ( + Path("server.py"), + None, + "test-server", + [], + {}, + ) + mock_install.return_value = True + + with patch("sys.exit") as mock_exit: + cursor_command("server.py") + + mock_install.assert_called_once_with( + file=Path("server.py"), + server_object=None, + name="test-server", + with_editable=None, + with_packages=[], + env_vars={}, + ) + mock_exit.assert_not_called() + + @patch("fastmcp.cli.install.cursor.install_cursor") + @patch("fastmcp.cli.install.cursor.process_common_args") + def test_cursor_command_failure(self, mock_process_args, mock_install): + """Test cursor command when installation fails.""" + mock_process_args.return_value = ( + Path("server.py"), + None, + "test-server", + [], + {}, + ) + mock_install.return_value = False + + with pytest.raises(SystemExit) as exc_info: + cursor_command("server.py") + + assert exc_info.value.code == 1 diff --git a/tests/cli/test_shared.py b/tests/cli/test_shared.py new file mode 100644 index 000000000..01d67b966 --- /dev/null +++ b/tests/cli/test_shared.py @@ -0,0 +1,31 @@ +"""Tests for shared CLI functionality.""" + +from fastmcp.cli.cli import _parse_env_var + + +class TestEnvVarParsing: + """Test environment variable parsing functionality.""" + + def test_parse_env_var_simple(self): + """Test parsing simple environment variable.""" + key, value = _parse_env_var("API_KEY=secret123") + assert key == "API_KEY" + assert value == "secret123" + + def test_parse_env_var_with_equals_in_value(self): + """Test parsing env var with equals signs in the value.""" + key, value = _parse_env_var("DATABASE_URL=postgresql://user:pass@host:5432/db") + assert key == "DATABASE_URL" + assert value == "postgresql://user:pass@host:5432/db" + + def test_parse_env_var_with_spaces(self): + """Test parsing env var with spaces (should be stripped).""" + key, value = _parse_env_var(" API_KEY = secret with spaces ") + assert key == "API_KEY" + assert value == "secret with spaces" + + def test_parse_env_var_empty_value(self): + """Test parsing env var with empty value.""" + key, value = _parse_env_var("EMPTY_VAR=") + assert key == "EMPTY_VAR" + assert value == ""