diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py index 9c39a5dbd..6c95749ab 100644 --- a/src/fastmcp/cli/run.py +++ b/src/fastmcp/cli/run.py @@ -387,7 +387,6 @@ async def run_with_reload( """ watch_paths = reload_dirs or [Path.cwd()] process: asyncio.subprocess.Process | None = None - first_run = True if is_stdio: logger.info("Reload mode enabled (using stateless sessions)") @@ -413,15 +412,8 @@ async def run_with_reload( try: while not shutdown_event.is_set(): - # Build command - add --no-banner on restarts to reduce noise - if first_run or "--no-banner" in cmd: - run_cmd = cmd - else: - run_cmd = [*cmd, "--no-banner"] - first_run = False - process = await asyncio.create_subprocess_exec( - *run_cmd, + *cmd, stdin=None, stdout=None, stderr=None, diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index e55dea697..91a7a2ea7 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -1,3 +1,4 @@ +import asyncio import inspect import json import subprocess @@ -13,6 +14,7 @@ from fastmcp.cli.run import ( create_mcp_config_server, is_url, run_module_command, + run_with_reload, ) from fastmcp.client.client import Client from fastmcp.client.transports import FastMCPTransport @@ -864,6 +866,99 @@ class TestRunModuleMode: assert "my_module" in cmd +class TestRunWithReloadWithServerArgs: + """Test the run command with reload(run_with_reload) with server args.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "reload_cmd", + [ + [ + "fastmcp", + "run", + "my_module", + "--module", + "--no-reload", + "--no-banner", + "--", + "--debug", + ], + [ + "fastmcp", + "run", + "my_module", + "--no-banner", + "--no-reload", + "--stateless", + "--", + "--debug", + ], + ["fastmcp", "run", "my_module", "--module", "--no-reload", "--", "--debug"], + [ + "fastmcp", + "run", + "my_module", + "--no-reload", + "--stateless", + "--", + "--debug", + ], + ], + ) + async def test_run_with_reload_does_not_mutate_command(self, reload_cmd, caplog): + """ + Test for issue 4081: + Verify that run_with_reload does NOT mutate the command arguments. + The command used for restart must be identical to the original command + to ensure nothing is incorrectly appended or reordered. + """ + reload_dirs = [Path(".")] + shutdown_event = asyncio.Event() + call_count = 0 + + async def mock_create_subprocess(*args, **kwargs): + nonlocal call_count + call_count += 1 + + proc = AsyncMock() + proc.kill = MagicMock() + proc.terminate = MagicMock() + proc.wait.return_value = 0 + proc.pid = 99999 + proc.returncode = None + + if call_count >= 2: + actual_args = list(args) + try: + # VALIDATION: reload_cmd remains UNCHANGED + assert actual_args == reload_cmd, ( + f"Regression: Command was mutated during reload.\n" + f"Expected: {reload_cmd}\n" + f"Actual: {actual_args}" + ) + finally: + shutdown_event.set() + + return proc + + mock_watch = MagicMock() + mock_watch.__aiter__.return_value = iter([[("Change.modified", "server.py")]]) + + with ( + patch( + "fastmcp.cli.run.asyncio.create_subprocess_exec", + side_effect=mock_create_subprocess, + ), + patch("fastmcp.cli.run.awatch", return_value=mock_watch), + patch("fastmcp.cli.run.asyncio.Event", return_value=shutdown_event), + caplog.at_level("INFO"), + ): + await run_with_reload(reload_cmd, reload_dirs=reload_dirs) + + assert any("Detected changes" in record.message for record in caplog.records) + assert call_count == 2, f"Restart logic was not triggered for {reload_cmd}" + + class TestInspectorModuleMode: """Test the inspector command's module-mode handling."""