fastmcp/tests/cli/test_cursor.py
Jeremiah Lowin 84f0229acc
Add Claude Code install integration (#1053)
* Add Cursor support

* Use url-safe encoding

* Add claude code integration

* Delete test_install_dependencies.py

* Fix windows tests
2025-07-05 21:23:47 -04:00

276 lines
10 KiB
Python

"""Tests for Cursor CLI integration."""
import base64
import json
from pathlib import Path
from unittest.mock import patch
from fastmcp.cli.install.cursor import (
generate_cursor_deeplink,
install_cursor,
open_deeplink,
)
from fastmcp.mcp_config import StdioMCPServer
class TestGenerateCursorDeeplink:
"""Test generate_cursor_deeplink function."""
def test_generates_valid_deeplink(self):
"""Should generate a valid Cursor deeplink with base64 encoded config."""
server_config = StdioMCPServer(
command="uv",
args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"],
env={"API_KEY": "secret"},
)
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
def test_config_is_url_safe_base64(self):
"""Should use URL-safe base64 encoding for the config."""
server_config = StdioMCPServer(
command="test",
args=["arg1", "arg2"],
)
deeplink = generate_cursor_deeplink("test", server_config)
# Extract the config parameter
config_param = deeplink.split("config=")[1]
# Should be decodable as URL-safe base64
decoded = base64.urlsafe_b64decode(config_param.encode())
config_data = json.loads(decoded)
assert config_data["command"] == "test"
assert config_data["args"] == ["arg1", "arg2"]
def test_excludes_none_values(self):
"""Should exclude None values from the configuration."""
server_config = StdioMCPServer(
command="test",
args=["arg1"],
timeout=None, # This should be excluded
)
deeplink = generate_cursor_deeplink("test", server_config)
config_param = deeplink.split("config=")[1]
decoded = base64.urlsafe_b64decode(config_param.encode())
config_data = json.loads(decoded)
assert "timeout" not in config_data
class TestOpenDeeplink:
"""Test open_deeplink function."""
@patch("subprocess.run")
@patch("fastmcp.cli.install.cursor.sys.platform", "darwin")
def test_opens_on_macos(self, mock_run):
"""Should use 'open' command on macOS."""
mock_run.return_value = None
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")
@patch("fastmcp.cli.install.cursor.sys.platform", "win32")
def test_opens_on_windows(self, mock_run):
"""Should use 'start' command on Windows."""
mock_run.return_value = None
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")
@patch("fastmcp.cli.install.cursor.sys.platform", "linux")
def test_opens_on_linux(self, mock_run):
"""Should use 'xdg-open' command on Linux."""
mock_run.return_value = None
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_handles_subprocess_error(self, mock_run):
"""Should return False when subprocess command fails."""
from subprocess import CalledProcessError
mock_run.side_effect = CalledProcessError(1, "open")
result = open_deeplink("cursor://test")
assert result is False
@patch("subprocess.run")
def test_handles_file_not_found(self, mock_run):
"""Should return False when command is not found."""
mock_run.side_effect = FileNotFoundError()
result = open_deeplink("cursor://test")
assert result is False
class TestInstallCursor:
"""Test install_cursor function."""
@patch("fastmcp.cli.install.cursor.open_deeplink")
@patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
@patch("fastmcp.cli.install.cursor.print")
def test_successful_installation(
self, mock_print, mock_generate_deeplink, mock_open_deeplink
):
"""Should successfully install when deeplink opens."""
mock_generate_deeplink.return_value = "cursor://test-deeplink"
mock_open_deeplink.return_value = True
result = install_cursor(Path("server.py"), None, "test-server")
assert result is True
mock_generate_deeplink.assert_called_once()
mock_open_deeplink.assert_called_once_with("cursor://test-deeplink")
mock_print.assert_called_once()
# Check that the success message was printed
assert "Opening Cursor to install" in str(mock_print.call_args)
@patch("fastmcp.cli.install.cursor.open_deeplink")
@patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
@patch("fastmcp.cli.install.cursor.print")
def test_fallback_when_deeplink_fails(
self, mock_print, mock_generate_deeplink, mock_open_deeplink
):
"""Should provide manual link when deeplink fails to open."""
mock_generate_deeplink.return_value = "cursor://test-deeplink"
mock_open_deeplink.return_value = False
result = install_cursor(Path("server.py"), None, "test-server")
assert result is True
assert mock_print.call_count == 2
# Check that both error and manual link messages were printed
print_calls = [str(call) for call in mock_print.call_args_list]
assert any(
"Could not open Cursor automatically" in call for call in print_calls
)
assert any("Please open this link" in call for call in print_calls)
@patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
@patch("fastmcp.cli.install.cursor.print")
def test_handles_deeplink_generation_error(
self, mock_print, mock_generate_deeplink
):
"""Should return False when deeplink generation fails."""
mock_generate_deeplink.side_effect = Exception("Test error")
result = install_cursor(Path("server.py"), None, "test-server")
assert result is False
mock_print.assert_called_once()
assert "Failed to generate Cursor deeplink" in str(mock_print.call_args)
@patch("fastmcp.cli.install.cursor.open_deeplink")
@patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
def test_builds_correct_server_config(
self, mock_generate_deeplink, mock_open_deeplink
):
"""Should build correct server configuration with all options."""
mock_generate_deeplink.return_value = "cursor://test"
mock_open_deeplink.return_value = True
install_cursor(
file=Path("server.py"),
server_object="custom_server",
name="test-server",
with_editable=Path("/path/to/editable"),
with_packages=["pandas", "requests"],
env_vars={"API_KEY": "secret", "DEBUG": "true"},
)
# Check that generate_cursor_deeplink was called with correct config
call_args = mock_generate_deeplink.call_args
server_name, server_config = call_args[0]
assert server_name == "test-server"
assert server_config.command == "uv"
assert "run" in server_config.args
assert "--with" in server_config.args
assert "fastmcp" in server_config.args
assert "pandas" in server_config.args
assert "requests" in server_config.args
assert "--with-editable" in server_config.args
assert str(Path("/path/to/editable")) in server_config.args
assert "fastmcp" in server_config.args
assert "run" in server_config.args
assert server_config.env == {"API_KEY": "secret", "DEBUG": "true"}
@patch("fastmcp.cli.install.cursor.open_deeplink")
@patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
def test_resolves_absolute_paths(self, mock_generate_deeplink, mock_open_deeplink):
"""Should resolve server spec to absolute path."""
mock_generate_deeplink.return_value = "cursor://test"
mock_open_deeplink.return_value = True
install_cursor(Path("server.py"), None, "test-server")
call_args = mock_generate_deeplink.call_args
_, server_config = call_args[0]
# Find the server spec after "fastmcp run"
server_spec_in_args = None
for i, arg in enumerate(server_config.args):
if (
arg == "fastmcp"
and i + 2 < len(server_config.args)
and server_config.args[i + 1] == "run"
):
server_spec_in_args = server_config.args[i + 2]
break
assert server_spec_in_args is not None
assert str(Path("server.py").resolve()) in server_spec_in_args
@patch("fastmcp.cli.install.cursor.open_deeplink")
@patch("fastmcp.cli.install.cursor.generate_cursor_deeplink")
def test_handles_server_spec_with_object(
self, mock_generate_deeplink, mock_open_deeplink
):
"""Should correctly handle server spec with object notation."""
mock_generate_deeplink.return_value = "cursor://test"
mock_open_deeplink.return_value = True
install_cursor(Path("server.py"), "custom_object", "test-server")
call_args = mock_generate_deeplink.call_args
_, server_config = call_args[0]
# Find the server spec after "fastmcp run"
server_spec_in_args = None
for i, arg in enumerate(server_config.args):
if (
arg == "fastmcp"
and i + 2 < len(server_config.args)
and server_config.args[i + 1] == "run"
):
server_spec_in_args = server_config.args[i + 2]
break
assert server_spec_in_args is not None
assert ":custom_object" in server_spec_in_args
assert str(Path("server.py").resolve()) in server_spec_in_args