fastmcp/tests/cli/test_cli.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

638 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