diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 07b2ddb5b..1605f567b 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -212,6 +212,13 @@ async def inspector( help="Directories to watch for changes (default: current directory)", ), ] = None, + module: Annotated[ + bool, + cyclopts.Parameter( + name=["--module", "-m"], + help="Run a Python module (python -m ) instead of importing a server object", + ), + ] = False, ) -> None: """Run an MCP server with the MCP Inspector for development. @@ -278,6 +285,10 @@ async def inspector( # Build the fastmcp run command fastmcp_cmd = ["fastmcp", "run", server_spec, "--no-banner"] + # Forward module mode flag + if module: + fastmcp_cmd.append("--module") + # Add reload flags if enabled - the server will handle reloading if reload: fastmcp_cmd.append("--reload") @@ -424,6 +435,13 @@ async def run( help="Run in stateless mode (no session, used internally for reload)", ), ] = False, + module: Annotated[ + bool, + cyclopts.Parameter( + name=["--module", "-m"], + help="Run a Python module (python -m ) instead of importing a server object", + ), + ] = False, ) -> None: """Run an MCP server or connect to a remote one. @@ -434,6 +452,7 @@ async def run( 4. MCPConfig file: "mcp.json" - runs as a proxy server for the MCP Servers in the MCPConfig file 5. FastMCP config: "fastmcp.json" - runs server using FastMCP configuration 6. No argument: looks for fastmcp.json in current directory + 7. Module mode: "my_module -m" - runs the module directly via python -m Server arguments can be passed after -- : fastmcp run server.py -- --config config.json --debug @@ -442,6 +461,54 @@ async def run( server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect """ + # --- Module mode: delegate to python -m and exit early --- + if module: + if server_spec is None: + logger.error("A module name is required when using --module / -m") + sys.exit(1) + + # Warn about options that are ignored in module mode + ignored_options: list[str] = [] + if transport: + ignored_options.append("--transport") + if host: + ignored_options.append("--host") + if port: + ignored_options.append("--port") + if path: + ignored_options.append("--path") + if reload: + ignored_options.append("--reload") + if ignored_options: + logger.warning( + f"Options {', '.join(ignored_options)} are ignored in module mode " + f"(-m). The module manages its own server startup." + ) + + # Build environment wrapper if needed + env_builder = None + if not skip_env and not is_already_in_uv_subprocess(): + from fastmcp.utilities.mcp_server_config.v1.environments.uv import ( + UVEnvironment, + ) + + env = UVEnvironment( + python=python, + dependencies=with_packages or None, + requirements=with_requirements, + project=project, + ) + test_cmd = ["test"] + if env.build_command(test_cmd) != test_cmd: + env_builder = env.build_command + + run_module.run_module_command( + server_spec, + env_command_builder=env_builder, + extra_args=list(server_args) if server_args else None, + ) + return + # Check if we were spawned by uv (or user explicitly set --skip-env) if skip_env or is_already_in_uv_subprocess(): skip_env = True diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index a662c79f3..46cbae3c1 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -6,6 +6,7 @@ import json import os import re import signal +import subprocess import sys from pathlib import Path from typing import Any, Literal @@ -255,6 +256,41 @@ async def run_command( sys.exit(1) +def run_module_command( + module_name: str, + *, + env_command_builder: Any | None = None, + extra_args: list[str] | None = None, +) -> None: + """Run a Python module directly using ``python -m ``. + + When ``-m`` is used, the module manages its own server startup. + No server-object discovery or transport overrides are applied. + + Args: + module_name: Dotted module name (e.g. ``my_package``). + env_command_builder: An optional callable that wraps a command list + with environment setup (e.g. ``UVEnvironment.build_command``). + extra_args: Extra arguments forwarded after the module name. + """ + cmd: list[str] = [sys.executable, "-m", module_name] + if extra_args: + cmd.extend(extra_args) + + # Wrap with environment (e.g. uv run) if configured + if env_command_builder is not None: + cmd = env_command_builder(cmd) + + logger.debug(f"Running module: {' '.join(cmd)}") + + try: + process = subprocess.run(cmd, check=True) + sys.exit(process.returncode) + except subprocess.CalledProcessError as e: + logger.error(f"Module {module_name} exited with code {e.returncode}") + sys.exit(e.returncode) + + async def run_v1_server_async( server: FastMCP1x, host: str | None = None, diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 184ea1dde..bcb8e1a78 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -695,3 +695,118 @@ class TestReloadFunctionality: } for ext in expected: assert ext in WATCHED_EXTENSIONS, f"Expected {ext} in WATCHED_EXTENSIONS" + + +class TestRunModuleCommand: + """Test run_module_command functionality.""" + + def test_runs_python_m_module(self): + """Test that run_module_command invokes python -m .""" + from unittest.mock import MagicMock, patch + + from fastmcp.cli.run import run_module_command + + mock_result = MagicMock() + mock_result.returncode = 0 + + with ( + patch( + "fastmcp.cli.run.subprocess.run", return_value=mock_result + ) as mock_run, + pytest.raises(SystemExit) as exc_info, + ): + run_module_command("my_package") + + assert exc_info.value.code == 0 + call_args = mock_run.call_args + cmd = call_args[0][0] + assert "-m" in cmd + assert "my_package" in cmd + + def test_forwards_extra_args(self): + """Test that extra arguments are forwarded after the module name.""" + from unittest.mock import MagicMock, patch + + from fastmcp.cli.run import run_module_command + + mock_result = MagicMock() + mock_result.returncode = 0 + + with ( + patch( + "fastmcp.cli.run.subprocess.run", return_value=mock_result + ) as mock_run, + pytest.raises(SystemExit), + ): + run_module_command("my_package", extra_args=["--host", "0.0.0.0"]) + + cmd = mock_run.call_args[0][0] + assert "--host" in cmd + assert "0.0.0.0" in cmd + + def test_uses_env_command_builder(self): + """Test that env_command_builder wraps the command.""" + from unittest.mock import MagicMock, patch + + from fastmcp.cli.run import run_module_command + + mock_result = MagicMock() + mock_result.returncode = 0 + + def fake_builder(cmd: list[str]) -> list[str]: + return ["uv", "run", *cmd] + + with ( + patch( + "fastmcp.cli.run.subprocess.run", return_value=mock_result + ) as mock_run, + pytest.raises(SystemExit), + ): + run_module_command("my_package", env_command_builder=fake_builder) + + cmd = mock_run.call_args[0][0] + assert cmd[0] == "uv" + assert cmd[1] == "run" + assert "-m" in cmd + assert "my_package" in cmd + + def test_exits_with_subprocess_error_code(self): + """Test that non-zero exit codes from the module are propagated.""" + import subprocess + from unittest.mock import patch + + from fastmcp.cli.run import run_module_command + + with ( + patch( + "fastmcp.cli.run.subprocess.run", + side_effect=subprocess.CalledProcessError(42, ["python", "-m", "bad"]), + ), + pytest.raises(SystemExit) as exc_info, + ): + run_module_command("bad") + + assert exc_info.value.code == 42 + + def test_no_env_builder_runs_plain_python(self): + """Test that without env_command_builder, plain python is used.""" + import sys + from unittest.mock import MagicMock, patch + + from fastmcp.cli.run import run_module_command + + mock_result = MagicMock() + mock_result.returncode = 0 + + with ( + patch( + "fastmcp.cli.run.subprocess.run", return_value=mock_result + ) as mock_run, + pytest.raises(SystemExit), + ): + run_module_command("my_module", env_command_builder=None) + + cmd = mock_run.call_args[0][0] + assert cmd[0] == sys.executable + assert cmd[1] == "-m" + assert cmd[2] == "my_module"