mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
357 lines
12 KiB
Python
357 lines
12 KiB
Python
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
|
|
|
|
# Use an absolute path that works on all platforms
|
|
editable_path = Path.cwd() / "local" / "package"
|
|
|
|
result = install_cursor(
|
|
file=Path("/path/to/server.py"),
|
|
server_object="custom_app",
|
|
name="test-server",
|
|
with_editable=[editable_path],
|
|
)
|
|
|
|
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"]
|
|
# Check that the path was resolved (should be absolute)
|
|
editable_idx = config_data["args"].index("--with-editable") + 1
|
|
resolved_path = config_data["args"][editable_idx]
|
|
assert Path(resolved_path).is_absolute()
|
|
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("--with numpy") == 1
|
|
assert args_str.count("--with pandas") == 1
|
|
assert args_str.count("--with fastmcp") == 1
|
|
|
|
|
|
class TestCursorCommand:
|
|
"""Test the cursor CLI command."""
|
|
|
|
@patch("fastmcp.cli.install.cursor.install_cursor")
|
|
@patch("fastmcp.cli.install.cursor.process_common_args")
|
|
async 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:
|
|
await cursor_command("server.py")
|
|
|
|
mock_install.assert_called_once_with(
|
|
file=Path("server.py"),
|
|
server_object=None,
|
|
name="test-server",
|
|
with_editable=[],
|
|
with_packages=[],
|
|
env_vars={},
|
|
python_version=None,
|
|
with_requirements=None,
|
|
project=None,
|
|
workspace=None,
|
|
)
|
|
mock_exit.assert_not_called()
|
|
|
|
@patch("fastmcp.cli.install.cursor.install_cursor")
|
|
@patch("fastmcp.cli.install.cursor.process_common_args")
|
|
async 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:
|
|
await cursor_command("server.py")
|
|
|
|
assert isinstance(exc_info.value, SystemExit)
|
|
assert exc_info.value.code == 1
|