diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py index ee0edb2c6..d8cf62859 100644 --- a/src/fastmcp/cli/install/cursor.py +++ b/src/fastmcp/cli/install/cursor.py @@ -1,10 +1,12 @@ """Cursor integration for FastMCP install using Cyclopts.""" import base64 +import os import subprocess import sys from pathlib import Path from typing import Annotated +from urllib.parse import urlparse import cyclopts from rich import print @@ -51,17 +53,20 @@ def open_deeplink(deeplink: str) -> bool: Returns: True if the command succeeded, False otherwise """ + parsed = urlparse(deeplink) + if parsed.scheme != "cursor": + logger.warning(f"Invalid deeplink scheme: {parsed.scheme}") + return False + try: if sys.platform == "darwin": # macOS subprocess.run(["open", deeplink], check=True, capture_output=True) elif sys.platform == "win32": # Windows - subprocess.run( - ["cmd", "/c", "start", deeplink], check=True, capture_output=True - ) + os.startfile(deeplink) else: # Linux and others subprocess.run(["xdg-open", deeplink], check=True, capture_output=True) return True - except (subprocess.CalledProcessError, FileNotFoundError): + except (subprocess.CalledProcessError, FileNotFoundError, OSError): return False diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index a1af8241d..ced1cfa62 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -135,18 +135,16 @@ def test_open_deeplink_macos(self, mock_run): ["open", "cursor://test"], check=True, capture_output=True ) - @patch("subprocess.run") - def test_open_deeplink_windows(self, mock_run): + def test_open_deeplink_windows(self): """Test opening deeplink on Windows.""" with patch("sys.platform", "win32"): - mock_run.return_value = Mock(returncode=0) + with patch( + "fastmcp.cli.install.cursor.os.startfile", create=True + ) as mock_startfile: + result = open_deeplink("cursor://test") - result = open_deeplink("cursor://test") - - assert result is True - mock_run.assert_called_once_with( - ["cmd", "/c", "start", "cursor://test"], check=True, capture_output=True - ) + assert result is True + mock_startfile.assert_called_once_with("cursor://test") @patch("subprocess.run") def test_open_deeplink_linux(self, mock_run): @@ -166,21 +164,57 @@ def test_open_deeplink_failure(self, mock_run): """Test handling of deeplink opening failure.""" import subprocess - mock_run.side_effect = subprocess.CalledProcessError(1, ["open"]) + with patch("sys.platform", "darwin"): + mock_run.side_effect = subprocess.CalledProcessError(1, ["open"]) - result = open_deeplink("cursor://test") + result = open_deeplink("cursor://test") - assert result is False + 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() + with patch("sys.platform", "darwin"): + mock_run.side_effect = FileNotFoundError() - result = open_deeplink("cursor://test") + result = open_deeplink("cursor://test") + + assert result is False + + def test_open_deeplink_invalid_scheme(self): + """Test that non-cursor:// URLs are rejected.""" + result = open_deeplink("http://malicious.com") + assert result is False + + result = open_deeplink("https://example.com") + assert result is False + result = open_deeplink("file:///etc/passwd") assert result is False + def test_open_deeplink_valid_cursor_scheme(self): + """Test that cursor:// URLs are accepted.""" + with patch("sys.platform", "darwin"): + with patch("subprocess.run") as mock_run: + mock_run.return_value = Mock(returncode=0) + result = open_deeplink("cursor://anysphere.cursor-deeplink/mcp/install") + assert result is True + + def test_open_deeplink_empty_url(self): + """Test handling of empty URL.""" + result = open_deeplink("") + assert result is False + + def test_open_deeplink_windows_oserror(self): + """Test handling of OSError on Windows.""" + with patch("sys.platform", "win32"): + with patch( + "fastmcp.cli.install.cursor.os.startfile", create=True + ) as mock_startfile: + mock_startfile.side_effect = OSError("File not found") + result = open_deeplink("cursor://test") + assert result is False + class TestInstallCursor: """Test cursor installation functionality."""