60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
639 lines
22 KiB
Python
639 lines
22 KiB
Python
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from fastmcp.cli.cli import _parse_env_var, app
|
|
|
|
|
|
class TestMainCLI:
|
|
"""Test the main CLI application."""
|
|
|
|
def test_app_exists(self):
|
|
"""Test that the main app is properly configured."""
|
|
# app.name is a tuple in cyclopts
|
|
assert "fastmcp" in app.name
|
|
assert "FastMCP" in app.help
|
|
# Just check that version exists, not the specific value
|
|
assert hasattr(app, "version")
|
|
|
|
def test_parse_env_var_valid(self):
|
|
"""Test parsing valid environment variables."""
|
|
key, value = _parse_env_var("KEY=value")
|
|
assert key == "KEY"
|
|
assert value == "value"
|
|
|
|
key, value = _parse_env_var("COMPLEX_KEY=complex=value=with=equals")
|
|
assert key == "COMPLEX_KEY"
|
|
assert value == "complex=value=with=equals"
|
|
|
|
def test_parse_env_var_invalid(self):
|
|
"""Test parsing invalid environment variables exits."""
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
_parse_env_var("INVALID_FORMAT")
|
|
assert isinstance(exc_info.value, SystemExit)
|
|
assert exc_info.value.code == 1
|
|
|
|
|
|
class TestVersionCommand:
|
|
"""Test the version command."""
|
|
|
|
@patch("fastmcp.cli.cli.check_for_newer_version", return_value=None)
|
|
def test_version_command_execution(self, mock_check):
|
|
"""Test that version command executes properly."""
|
|
# The version command should execute without raising SystemExit
|
|
command, bound, _ = app.parse_args(["version"])
|
|
command() # Should not raise
|
|
|
|
def test_version_command_parsing(self):
|
|
"""Test that the version command parses arguments correctly."""
|
|
command, bound, _ = app.parse_args(["version"])
|
|
assert callable(command)
|
|
assert command.__name__ == "version" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
|
# Default arguments aren't included in bound.arguments
|
|
assert bound.arguments == {}
|
|
|
|
def test_version_command_with_copy_flag(self):
|
|
"""Test that the version command parses --copy flag correctly."""
|
|
command, bound, _ = app.parse_args(["version", "--copy"])
|
|
assert callable(command)
|
|
assert command.__name__ == "version" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute]
|
|
assert bound.arguments == {"copy": True}
|
|
|
|
@patch("fastmcp.cli.cli.pyperclip.copy")
|
|
@patch("fastmcp.cli.cli.console")
|
|
def test_version_command_copy_functionality(
|
|
self, mock_console, mock_pyperclip_copy
|
|
):
|
|
"""Test that the version command copies to clipboard when --copy is used."""
|
|
command, bound, _ = app.parse_args(["version", "--copy"])
|
|
command(**bound.arguments)
|
|
|
|
# Verify pyperclip.copy was called with plain text format
|
|
mock_pyperclip_copy.assert_called_once()
|
|
copied_text = mock_pyperclip_copy.call_args[0][0]
|
|
|
|
# Verify the copied text contains expected version info keys in plain text
|
|
assert "FastMCP version:" in copied_text
|
|
assert "MCP version:" in copied_text
|
|
assert "Python version:" in copied_text
|
|
assert "Platform:" in copied_text
|
|
assert "FastMCP root path:" in copied_text
|
|
|
|
# Verify no ANSI escape codes (terminal control characters)
|
|
assert "\x1b[" not in copied_text
|
|
mock_console.print.assert_called_with(
|
|
"[green]✓[/green] Version information copied to clipboard"
|
|
)
|
|
|
|
|
|
class TestDevCommand:
|
|
"""Test the dev command."""
|
|
|
|
def test_dev_inspector_command_parsing(self):
|
|
"""Test that dev inspector command can be parsed with various options."""
|
|
# Test basic parsing
|
|
command, bound, _ = app.parse_args(["dev", "inspector", "server.py"])
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
|
|
# Test with options
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"dev",
|
|
"inspector",
|
|
"server.py",
|
|
"--with",
|
|
"package1",
|
|
"--inspector-version",
|
|
"1.0.0",
|
|
"--ui-port",
|
|
"3000",
|
|
]
|
|
)
|
|
assert bound.arguments["with_packages"] == ["package1"]
|
|
assert bound.arguments["inspector_version"] == "1.0.0"
|
|
assert bound.arguments["ui_port"] == 3000
|
|
|
|
def test_dev_inspector_command_parsing_with_new_options(self):
|
|
"""Test dev inspector command parsing with new uv options."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"dev",
|
|
"inspector",
|
|
"server.py",
|
|
"--python",
|
|
"3.10",
|
|
"--project",
|
|
"/workspace",
|
|
"--with-requirements",
|
|
"dev-requirements.txt",
|
|
"--with",
|
|
"pytest",
|
|
]
|
|
)
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["python"] == "3.10"
|
|
assert bound.arguments["project"] == Path("/workspace")
|
|
assert bound.arguments["with_requirements"] == Path("dev-requirements.txt")
|
|
assert bound.arguments["with_packages"] == ["pytest"]
|
|
|
|
|
|
class TestRunCommand:
|
|
"""Test the run command."""
|
|
|
|
def test_run_command_parsing_basic(self):
|
|
"""Test basic run command parsing."""
|
|
command, bound, _ = app.parse_args(["run", "server.py"])
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
# Cyclopts only includes non-default values
|
|
assert "transport" not in bound.arguments
|
|
assert "host" not in bound.arguments
|
|
assert "port" not in bound.arguments
|
|
assert "path" not in bound.arguments
|
|
assert "log_level" not in bound.arguments
|
|
assert "no_banner" not in bound.arguments
|
|
|
|
def test_run_command_parsing_with_options(self):
|
|
"""Test run command parsing with various options."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--transport",
|
|
"http",
|
|
"--host",
|
|
"localhost",
|
|
"--port",
|
|
"8080",
|
|
"--path",
|
|
"/v1/mcp",
|
|
"--log-level",
|
|
"DEBUG",
|
|
"--no-banner",
|
|
]
|
|
)
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["transport"] == "http"
|
|
assert bound.arguments["host"] == "localhost"
|
|
assert bound.arguments["port"] == 8080
|
|
assert bound.arguments["path"] == "/v1/mcp"
|
|
assert bound.arguments["log_level"] == "DEBUG"
|
|
assert bound.arguments["no_banner"] is True
|
|
|
|
def test_run_command_parsing_partial_options(self):
|
|
"""Test run command parsing with only some options."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--transport",
|
|
"http",
|
|
"--no-banner",
|
|
]
|
|
)
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["transport"] == "http"
|
|
assert bound.arguments["no_banner"] is True
|
|
# Other options should not be present
|
|
assert "host" not in bound.arguments
|
|
assert "port" not in bound.arguments
|
|
assert "log_level" not in bound.arguments
|
|
assert "path" not in bound.arguments
|
|
|
|
def test_run_command_parsing_with_new_options(self):
|
|
"""Test run command parsing with new uv options."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--python",
|
|
"3.11",
|
|
"--with",
|
|
"pandas",
|
|
"--with",
|
|
"numpy",
|
|
"--project",
|
|
"/path/to/project",
|
|
"--with-requirements",
|
|
"requirements.txt",
|
|
]
|
|
)
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["python"] == "3.11"
|
|
assert bound.arguments["with_packages"] == ["pandas", "numpy"]
|
|
assert bound.arguments["project"] == Path("/path/to/project")
|
|
assert bound.arguments["with_requirements"] == Path("requirements.txt")
|
|
|
|
def test_run_command_transport_aliases(self):
|
|
"""Test that both 'http' and 'streamable-http' are accepted as valid transport options."""
|
|
# Test with 'http' transport
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--transport",
|
|
"http",
|
|
]
|
|
)
|
|
assert command is not None
|
|
assert bound.arguments["transport"] == "http"
|
|
|
|
# Test with 'streamable-http' transport
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--transport",
|
|
"streamable-http",
|
|
]
|
|
)
|
|
assert command is not None
|
|
assert bound.arguments["transport"] == "streamable-http"
|
|
|
|
def test_run_command_parsing_with_server_args(self):
|
|
"""Test run command parsing with server arguments after --."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--",
|
|
"--config",
|
|
"test.json",
|
|
"--debug",
|
|
]
|
|
)
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
# Server args after -- are captured as positional arguments in bound.args
|
|
assert bound.args == ("server.py", "--config", "test.json", "--debug")
|
|
|
|
def test_run_command_parsing_with_mixed_args(self):
|
|
"""Test run command parsing with both FastMCP options and server args."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--transport",
|
|
"http",
|
|
"--port",
|
|
"8080",
|
|
"--",
|
|
"--server-port",
|
|
"9090",
|
|
"--debug",
|
|
]
|
|
)
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["transport"] == "http"
|
|
assert bound.arguments["port"] == 8080
|
|
# Server args after -- are captured separately from FastMCP options
|
|
assert bound.args == ("server.py", "--server-port", "9090", "--debug")
|
|
|
|
def test_run_command_parsing_with_positional_server_args(self):
|
|
"""Test run command parsing with positional server arguments."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--",
|
|
"arg1",
|
|
"arg2",
|
|
"--flag",
|
|
]
|
|
)
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
# Positional args and flags after -- are all captured
|
|
assert bound.args == ("server.py", "arg1", "arg2", "--flag")
|
|
|
|
def test_run_command_parsing_server_args_require_delimiter(self):
|
|
"""Test that server args without -- delimiter are rejected."""
|
|
# Should fail because --config is not a recognized FastMCP option
|
|
with pytest.raises(SystemExit):
|
|
app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--config",
|
|
"test.json",
|
|
]
|
|
)
|
|
|
|
def test_run_command_parsing_project_flag(self):
|
|
"""Test run command parsing with --project flag."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--project",
|
|
"./test-env",
|
|
]
|
|
)
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["project"] == Path("./test-env")
|
|
|
|
def test_run_command_parsing_skip_source_flag(self):
|
|
"""Test run command parsing with --skip-source flag."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--skip-source",
|
|
]
|
|
)
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["skip_source"] is True
|
|
|
|
def test_run_command_parsing_project_and_skip_source(self):
|
|
"""Test run command parsing with --project and --skip-source flags."""
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"run",
|
|
"server.py",
|
|
"--project",
|
|
"./test-env",
|
|
"--skip-source",
|
|
]
|
|
)
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
assert bound.arguments["project"] == Path("./test-env")
|
|
assert bound.arguments["skip_source"] is True
|
|
|
|
def test_show_server_banner_setting(self):
|
|
"""Test that show_server_banner setting works with environment variable."""
|
|
import os
|
|
from unittest import mock
|
|
|
|
from fastmcp.settings import Settings
|
|
|
|
# Test default (banner shown)
|
|
settings = Settings()
|
|
assert settings.show_server_banner is True
|
|
|
|
# Test with env var set to false (banner hidden)
|
|
with mock.patch.dict(os.environ, {"FASTMCP_SHOW_SERVER_BANNER": "false"}):
|
|
settings = Settings()
|
|
assert settings.show_server_banner is False
|
|
|
|
# Test CLI precedence logic (simulated)
|
|
with mock.patch.dict(os.environ, {"FASTMCP_SHOW_SERVER_BANNER": "true"}):
|
|
settings = Settings()
|
|
# CLI --no-banner flag would override
|
|
cli_no_banner = True
|
|
final = cli_no_banner if cli_no_banner else not settings.show_server_banner
|
|
assert final is True # Banner suppressed by CLI flag
|
|
|
|
|
|
class TestWindowsSpecific:
|
|
"""Test Windows-specific functionality."""
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_npx_command_windows_cmd(self, mock_run):
|
|
"""Test npx command detection on Windows with npx.cmd."""
|
|
from fastmcp.cli.cli import _get_npx_command
|
|
|
|
with patch("sys.platform", "win32"):
|
|
# First call succeeds with npx.cmd
|
|
mock_run.return_value = Mock(returncode=0)
|
|
|
|
result = _get_npx_command()
|
|
|
|
assert result == "npx.cmd"
|
|
mock_run.assert_called_once_with(
|
|
["npx.cmd", "--version"],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_npx_command_windows_exe(self, mock_run):
|
|
"""Test npx command detection on Windows with npx.exe."""
|
|
from fastmcp.cli.cli import _get_npx_command
|
|
|
|
with patch("sys.platform", "win32"):
|
|
# First call fails, second succeeds
|
|
mock_run.side_effect = [
|
|
subprocess.CalledProcessError(1, "npx.cmd"),
|
|
Mock(returncode=0),
|
|
]
|
|
|
|
result = _get_npx_command()
|
|
|
|
assert result == "npx.exe"
|
|
assert mock_run.call_count == 2
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_npx_command_windows_cmd_missing(self, mock_run):
|
|
"""Test npx command detection continues when npx.cmd is missing."""
|
|
from fastmcp.cli.cli import _get_npx_command
|
|
|
|
with patch("sys.platform", "win32"):
|
|
# Missing npx.cmd should not abort detection
|
|
mock_run.side_effect = [
|
|
FileNotFoundError("npx.cmd not found"),
|
|
Mock(returncode=0),
|
|
]
|
|
|
|
result = _get_npx_command()
|
|
|
|
assert result == "npx.exe"
|
|
assert mock_run.call_count == 2
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_npx_command_windows_fallback(self, mock_run):
|
|
"""Test npx command detection on Windows with plain npx."""
|
|
from fastmcp.cli.cli import _get_npx_command
|
|
|
|
with patch("sys.platform", "win32"):
|
|
# First two calls fail, third succeeds
|
|
mock_run.side_effect = [
|
|
subprocess.CalledProcessError(1, "npx.cmd"),
|
|
subprocess.CalledProcessError(1, "npx.exe"),
|
|
Mock(returncode=0),
|
|
]
|
|
|
|
result = _get_npx_command()
|
|
|
|
assert result == "npx"
|
|
assert mock_run.call_count == 3
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_npx_command_windows_not_found(self, mock_run):
|
|
"""Test npx command detection on Windows when npx is not found."""
|
|
from fastmcp.cli.cli import _get_npx_command
|
|
|
|
with patch("sys.platform", "win32"):
|
|
# All calls fail
|
|
mock_run.side_effect = subprocess.CalledProcessError(1, "npx")
|
|
|
|
result = _get_npx_command()
|
|
|
|
assert result is None
|
|
assert mock_run.call_count == 3
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_npx_command_unix(self, mock_run):
|
|
"""Test npx command detection on Unix systems."""
|
|
from fastmcp.cli.cli import _get_npx_command
|
|
|
|
with patch("sys.platform", "darwin"):
|
|
result = _get_npx_command()
|
|
|
|
assert result == "npx"
|
|
mock_run.assert_not_called()
|
|
|
|
def test_windows_path_parsing_with_colon(self, tmp_path):
|
|
"""Test parsing Windows paths with drive letters and colons."""
|
|
from pathlib import Path
|
|
|
|
from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import (
|
|
FileSystemSource,
|
|
)
|
|
|
|
# Create a real test file to test the logic
|
|
test_file = tmp_path / "server.py"
|
|
test_file.write_text("# test server")
|
|
|
|
# Test normal file parsing (works on all platforms)
|
|
source = FileSystemSource(path=str(test_file))
|
|
assert source.entrypoint is None
|
|
assert Path(source.path).resolve() == test_file.resolve()
|
|
|
|
# Test file:object parsing
|
|
source = FileSystemSource(path=f"{test_file}:myapp")
|
|
assert source.entrypoint == "myapp"
|
|
|
|
# Test that the file portion resolves correctly when object is specified
|
|
assert Path(source.path).resolve() == test_file.resolve()
|
|
|
|
|
|
class TestInspectCommand:
|
|
"""Test the inspect command."""
|
|
|
|
def test_inspect_command_parsing_basic(self):
|
|
"""Test basic inspect command parsing."""
|
|
command, bound, _ = app.parse_args(["inspect", "server.py"])
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
# Only explicitly set parameters are in bound.arguments
|
|
assert "output" not in bound.arguments
|
|
|
|
def test_inspect_command_parsing_with_output(self, tmp_path):
|
|
"""Test inspect command parsing with output file."""
|
|
output_file = tmp_path / "output.json"
|
|
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"inspect",
|
|
"server.py",
|
|
"--output",
|
|
str(output_file),
|
|
]
|
|
)
|
|
|
|
assert command is not None
|
|
assert bound.arguments["server_spec"] == "server.py"
|
|
# Output is parsed as a Path object
|
|
assert bound.arguments["output"] == output_file
|
|
|
|
async def test_inspect_command_text_summary(self, tmp_path, capsys):
|
|
"""Test inspect command with no format shows text summary."""
|
|
# Create a real server file
|
|
server_file = tmp_path / "test_server.py"
|
|
server_file.write_text("""
|
|
import fastmcp
|
|
|
|
mcp = fastmcp.FastMCP("InspectTestServer", instructions="Test instructions", version="1.0.0")
|
|
|
|
@mcp.tool
|
|
def test_tool(x: int) -> int:
|
|
return x * 2
|
|
""")
|
|
|
|
# Parse and execute the command without format or output
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"inspect",
|
|
str(server_file),
|
|
]
|
|
)
|
|
|
|
await command(**bound.arguments)
|
|
|
|
# Check the console output
|
|
captured = capsys.readouterr()
|
|
# Check for the table format output
|
|
assert "InspectTestServer" in captured.out
|
|
assert "Test instructions" in captured.out
|
|
assert "1.0.0" in captured.out
|
|
assert "Tools" in captured.out
|
|
assert "1" in captured.out # number of tools
|
|
assert "FastMCP" in captured.out
|
|
assert "MCP" in captured.out
|
|
assert "Use --format [fastmcp|mcp] for complete JSON output" in captured.out
|
|
|
|
async def test_inspect_command_with_real_server(self, tmp_path):
|
|
"""Test inspect command with a real server file."""
|
|
# Create a real server file
|
|
server_file = tmp_path / "test_server.py"
|
|
server_file.write_text("""
|
|
import fastmcp
|
|
|
|
mcp = fastmcp.FastMCP("InspectTestServer")
|
|
|
|
@mcp.tool
|
|
def test_tool(x: int) -> int:
|
|
return x * 2
|
|
|
|
@mcp.prompt
|
|
def test_prompt(name: str) -> str:
|
|
return f"Hello, {name}!"
|
|
""")
|
|
|
|
output_file = tmp_path / "inspect_output.json"
|
|
|
|
# Parse and execute the command with format and output file
|
|
command, bound, _ = app.parse_args(
|
|
[
|
|
"inspect",
|
|
str(server_file),
|
|
"--format",
|
|
"fastmcp",
|
|
"--output",
|
|
str(output_file),
|
|
]
|
|
)
|
|
|
|
await command(**bound.arguments)
|
|
|
|
# Verify the output file was created and contains expected content
|
|
assert output_file.exists()
|
|
content = output_file.read_text()
|
|
|
|
# Basic checks that the fastmcp format worked
|
|
import json
|
|
|
|
data = json.loads(content)
|
|
assert data["server"]["name"] == "InspectTestServer"
|
|
assert len(data["tools"]) == 1
|
|
assert len(data["prompts"]) == 1
|