fastmcp/tests/test_cli.py
2024-12-01 20:55:16 -05:00

291 lines
8.8 KiB
Python

"""Tests for the FastMCP CLI."""
import json
from unittest.mock import patch
import pytest
from typer.testing import CliRunner
from fastmcp.cli.cli import app, _parse_env_var
@pytest.fixture
def mock_config(tmp_path):
"""Create a mock Claude config file."""
config = {"mcpServers": {}}
config_file = tmp_path / "claude_desktop_config.json"
config_file.write_text(json.dumps(config))
return config_file
@pytest.fixture
def server_file(tmp_path):
"""Create a server file."""
server_file = tmp_path / "server.py"
server_file.write_text(
"""from fastmcp import FastMCP
mcp = FastMCP("test")
"""
)
return server_file
@pytest.fixture
def mock_env_file(tmp_path):
"""Create a mock .env file."""
env_file = tmp_path / ".env"
env_file.write_text("FOO=bar\nBAZ=123")
return env_file
def test_parse_env_var():
"""Test parsing environment variables."""
assert _parse_env_var("FOO=bar") == ("FOO", "bar")
assert _parse_env_var("FOO=") == ("FOO", "")
assert _parse_env_var("FOO=bar baz") == ("FOO", "bar baz")
assert _parse_env_var("FOO = bar ") == ("FOO", "bar")
with pytest.raises(SystemExit):
_parse_env_var("invalid")
@pytest.mark.parametrize(
"args,expected_env",
[
# Basic env var
(
["--env-var", "FOO=bar"],
{"FOO": "bar"},
),
# Multiple env vars
(
["--env-var", "FOO=bar", "--env-var", "BAZ=123"],
{"FOO": "bar", "BAZ": "123"},
),
# Env var with spaces
(
["--env-var", "FOO=bar baz"],
{"FOO": "bar baz"},
),
],
)
def test_install_with_env_vars(mock_config, server_file, args, expected_env):
"""Test installing with environment variables."""
runner = CliRunner()
with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
mock_config_path.return_value = mock_config.parent
result = runner.invoke(
app,
["install", str(server_file)] + args,
)
assert result.exit_code == 0
# Read the config file and check env vars
config = json.loads(mock_config.read_text())
assert "mcpServers" in config
assert len(config["mcpServers"]) == 1
server = next(iter(config["mcpServers"].values()))
assert server["env"] == expected_env
def test_install_with_env_file(mock_config, server_file, mock_env_file):
"""Test installing with environment variables from a file."""
runner = CliRunner()
with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
mock_config_path.return_value = mock_config.parent
result = runner.invoke(
app,
["install", str(server_file), "--env-file", str(mock_env_file)],
)
assert result.exit_code == 0
# Read the config file and check env vars
config = json.loads(mock_config.read_text())
assert "mcpServers" in config
assert len(config["mcpServers"]) == 1
server = next(iter(config["mcpServers"].values()))
assert server["env"] == {"FOO": "bar", "BAZ": "123"}
def test_install_preserves_existing_env_vars(mock_config, server_file):
"""Test that installing preserves existing environment variables."""
# Set up initial config with env vars
config = {
"mcpServers": {
"test": {
"command": "uv",
"args": [
"run",
"--with",
"fastmcp",
"fastmcp",
"run",
str(server_file),
],
"env": {"FOO": "bar", "BAZ": "123"},
}
}
}
mock_config.write_text(json.dumps(config))
runner = CliRunner()
with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
mock_config_path.return_value = mock_config.parent
# Install with a new env var
result = runner.invoke(
app,
["install", str(server_file), "--env-var", "NEW=value"],
)
assert result.exit_code == 0
# Read the config file and check env vars are preserved
config = json.loads(mock_config.read_text())
server = next(iter(config["mcpServers"].values()))
assert server["env"] == {"FOO": "bar", "BAZ": "123", "NEW": "value"}
def test_install_updates_existing_env_vars(mock_config, server_file):
"""Test that installing updates existing environment variables."""
# Set up initial config with env vars
config = {
"mcpServers": {
"test": {
"command": "uv",
"args": [
"run",
"--with",
"fastmcp",
"fastmcp",
"run",
str(server_file),
],
"env": {"FOO": "bar", "BAZ": "123"},
}
}
}
mock_config.write_text(json.dumps(config))
runner = CliRunner()
with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
mock_config_path.return_value = mock_config.parent
# Update an existing env var
result = runner.invoke(
app,
["install", str(server_file), "--env-var", "FOO=newvalue"],
)
assert result.exit_code == 0
# Read the config file and check env var was updated
config = json.loads(mock_config.read_text())
server = next(iter(config["mcpServers"].values()))
assert server["env"] == {"FOO": "newvalue", "BAZ": "123"}
def test_server_dependencies(mock_config, server_file):
"""Test that server dependencies are correctly handled."""
# Create a server file with dependencies
server_file = server_file.parent / "server_with_deps.py"
server_file.write_text(
"""from fastmcp import FastMCP
mcp = FastMCP("test", dependencies=["pandas", "numpy"])
"""
)
runner = CliRunner()
with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
mock_config_path.return_value = mock_config.parent
result = runner.invoke(app, ["install", str(server_file)])
assert result.exit_code == 0
# Read the config file and check dependencies were added as --with args
config = json.loads(mock_config.read_text())
server = next(iter(config["mcpServers"].values()))
assert "--with" in server["args"]
assert "pandas" in server["args"]
assert "numpy" in server["args"]
def test_server_dependencies_empty(mock_config, server_file):
"""Test that server with no dependencies works correctly."""
runner = CliRunner()
with patch("fastmcp.cli.claude.get_claude_config_path") as mock_config_path:
mock_config_path.return_value = mock_config.parent
result = runner.invoke(app, ["install", str(server_file)])
assert result.exit_code == 0
# Read the config file and check only fastmcp is in --with args
config = json.loads(mock_config.read_text())
server = next(iter(config["mcpServers"].values()))
assert server["args"].count("--with") == 1
assert "fastmcp" in server["args"]
def test_dev_with_dependencies(mock_config, server_file):
"""Test that dev command handles dependencies correctly."""
# Create a server file with dependencies
server_file = server_file.parent / "server_with_deps.py"
server_file.write_text(
"""from fastmcp import FastMCP
mcp = FastMCP("test", dependencies=["pandas", "numpy"])
"""
)
runner = CliRunner()
with patch("subprocess.run") as mock_run:
mock_run.return_value.returncode = 0 # Set successful return code
result = runner.invoke(app, ["dev", str(server_file)])
assert result.exit_code == 0
# Check that dependencies were passed to subprocess.run
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert "npx" in args
assert "@modelcontextprotocol/inspector" in args
assert "uv" in args
assert "run" in args
assert "--with" in args
assert "pandas" in args
assert "numpy" in args
assert "fastmcp" in args
def test_run_with_dependencies(mock_config, server_file):
"""Test that run command does not handle dependencies."""
# Create a server file with dependencies
server_file = server_file.parent / "server_with_deps.py"
server_file.write_text(
"""from fastmcp import FastMCP
mcp = FastMCP("test", dependencies=["pandas", "numpy"])
if __name__ == "__main__":
mcp.run()
"""
)
runner = CliRunner()
with patch("subprocess.run") as mock_run:
result = runner.invoke(app, ["run", str(server_file)])
assert result.exit_code == 0
# Run command should not call subprocess.run
mock_run.assert_not_called()