Spaces:
Running
Running
| """Tests for the FastMCP CLI.""" | |
| import json | |
| import sys | |
| from pathlib import Path | |
| from unittest.mock import call, patch | |
| import pytest | |
| from typer.testing import CliRunner | |
| from fastmcp.cli.cli import _parse_env_var, _parse_file_path, app | |
| 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 | |
| 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 | |
| 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") | |
| 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_parse_file_path_windows_drive(): | |
| """Test parsing a Windows file path with a drive letter.""" | |
| file_spec = r"C:\path\to\file.txt" | |
| with ( | |
| patch("pathlib.Path.exists", return_value=True), | |
| patch("pathlib.Path.is_file", return_value=True), | |
| ): | |
| file_path, server_object = _parse_file_path(file_spec) | |
| assert file_path == Path(r"C:\path\to\file.txt").resolve() | |
| assert server_object is None | |
| def test_parse_file_path_with_object(): | |
| """Test parsing a file path with an object specification.""" | |
| file_spec = "/path/to/file.txt:object" | |
| with patch("sys.exit") as mock_exit: | |
| _parse_file_path(file_spec) | |
| # Check that sys.exit was called twice with code 1 | |
| assert mock_exit.call_count == 2 | |
| mock_exit.assert_has_calls([call(1), call(1)]) | |
| def test_parse_file_path_windows_with_object(): | |
| """Test parsing a Windows file path with an object specification.""" | |
| file_spec = r"C:\path\to\file.txt:object" | |
| with ( | |
| patch("pathlib.Path.exists", return_value=True), | |
| patch("pathlib.Path.is_file", return_value=True), | |
| ): | |
| file_path, server_object = _parse_file_path(file_spec) | |
| assert file_path == Path(r"C:\path\to\file.txt").resolve() | |
| assert server_object == "object" | |
| 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.""" | |
| 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 | |
| result = runner.invoke(app, ["dev", str(server_file)]) | |
| assert result.exit_code == 0 | |
| if sys.platform == "win32": | |
| # On Windows, expect two calls | |
| assert mock_run.call_count == 2 | |
| assert mock_run.call_args_list[0] == call( | |
| ["npx.cmd", "--version"], check=True, capture_output=True, shell=True | |
| ) | |
| # get the actual command and expected command without dependencies | |
| actual_cmd = mock_run.call_args_list[1][0][0] | |
| expected_start = [ | |
| "npx.cmd", | |
| "@modelcontextprotocol/inspector", | |
| "uv", | |
| "run", | |
| "--with", | |
| "fastmcp", | |
| ] | |
| expected_end = ["fastmcp", "run", str(server_file)] | |
| # verify start and end of command | |
| assert actual_cmd[: len(expected_start)] == expected_start | |
| assert actual_cmd[-len(expected_end) :] == expected_end | |
| # verify dependencies are present (order-independent) | |
| deps_section = actual_cmd[len(expected_start) : -len(expected_end)] | |
| assert all( | |
| x in deps_section for x in ["--with", "numpy", "--with", "pandas"] | |
| ) | |
| # Verify subprocess call kwargs, allowing for environment variables | |
| call_kwargs = mock_run.call_args_list[1][1] | |
| assert call_kwargs["check"] is True | |
| assert call_kwargs["shell"] is True | |
| assert isinstance(call_kwargs["env"], dict) | |
| else: | |
| # same verification for unix, just with different command prefix | |
| actual_cmd = mock_run.call_args_list[0][0][0] | |
| expected_start = [ | |
| "npx", | |
| "@modelcontextprotocol/inspector", | |
| "uv", | |
| "run", | |
| "--with", | |
| "fastmcp", | |
| ] | |
| expected_end = ["fastmcp", "run", str(server_file)] | |
| assert actual_cmd[: len(expected_start)] == expected_start | |
| assert actual_cmd[-len(expected_end) :] == expected_end | |
| deps_section = actual_cmd[len(expected_start) : -len(expected_end)] | |
| assert all( | |
| x in deps_section for x in ["--with", "numpy", "--with", "pandas"] | |
| ) | |
| # Verify subprocess call kwargs, allowing for environment variables | |
| call_kwargs = mock_run.call_args_list[0][1] | |
| assert call_kwargs["check"] is True | |
| assert call_kwargs["shell"] is False | |
| assert isinstance(call_kwargs["env"], dict) | |
| 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() | |