Add -m/--module flag to fastmcp run and dev inspector

This commit is contained in:
dgenio 2026-02-28 11:22:02 +00:00 committed by Jeremiah Lowin
commit 7a582ff5a0
3 changed files with 218 additions and 0 deletions

View file

@ -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 <module>) 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 <module>) 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

View file

@ -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 <module>``.
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,

View file

@ -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 <module>."""
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"