From 7fddab52a9cd647e9b44363761a84b7735919f3d Mon Sep 17 00:00:00 2001 From: itaru2622 <70509350+itaru2622@users.noreply.github.com> Date: Wed, 20 May 2026 22:37:32 +0900 Subject: [PATCH] feat: new options --host and --no-log-panel | --log-panel to cli dev apps (#4123) --- fastmcp_slim/fastmcp/cli/apps_dev.py | 64 ++++++++--- fastmcp_slim/fastmcp/cli/cli.py | 24 +++- tests/cli/test_run.py | 158 ++++++++++++++++++++++++++- 3 files changed, 231 insertions(+), 15 deletions(-) diff --git a/fastmcp_slim/fastmcp/cli/apps_dev.py b/fastmcp_slim/fastmcp/cli/apps_dev.py index 9bb2ecf0a..5a6c3003a 100644 --- a/fastmcp_slim/fastmcp/cli/apps_dev.py +++ b/fastmcp_slim/fastmcp/cli/apps_dev.py @@ -1375,6 +1375,7 @@ def _make_dev_app( app_bridge_js: str, import_map_tag: str, message_log: _MessageLog, + log_panel: bool, ) -> Starlette: """Build the Starlette dev server application.""" @@ -1391,7 +1392,11 @@ def _make_dev_app( on_open_link="bridge.onopenlink = async ({ url }) => { window.location.href = url; return {}; };", on_initialized="bridge.oninitialized = async () => {};", ) - return HTMLResponse(_inject_log_panel(host_html)) + return ( + HTMLResponse(_inject_log_panel(host_html)) + if log_panel + else HTMLResponse(host_html) + ) async def picker_app(request: Request) -> HTMLResponse: """Prefab picker UI — tool list with one tab per UI tool.""" @@ -1416,7 +1421,11 @@ def _make_dev_app( tool_args_json=json.dumps(tool_args), mcp_sdk_version=_MCP_SDK_VERSION, ) - return HTMLResponse(_inject_log_panel(host_html)) + return ( + HTMLResponse(_inject_log_panel(host_html)) + if log_panel + else HTMLResponse(host_html) + ) async def api_launch(request: Request) -> Response: """Picker form submits here; returns a /launch URL string for OpenLink.""" @@ -1525,7 +1534,9 @@ def _make_dev_app( # Use a reasonable default timeout to prevent the proxy from hanging # if the backend server is unresponsive. - client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=None)) + client = httpx.AsyncClient( + timeout=httpx.Timeout(60.0, read=None), trust_env=False + ) async def _stream_and_cleanup(resp: httpx.Response) -> Any: is_sse = "text/event-stream" in resp.headers.get("content-type", "") @@ -1653,6 +1664,7 @@ async def _start_user_server( mcp_port: int, *, reload: bool = True, + host: str = "127.0.0.1", ) -> asyncio.subprocess.Process: """Start the user's MCP server as a subprocess on mcp_port.""" cmd = [ @@ -1663,6 +1675,8 @@ async def _start_user_server( server_spec, "--transport", "http", + "--host", + host, "--port", str(mcp_port), "--no-banner", @@ -1684,7 +1698,7 @@ async def _wait_for_server(url: str, timeout: float = 15.0) -> bool: """Poll until the server is accepting connections.""" loop = asyncio.get_running_loop() deadline = loop.time() + timeout - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(trust_env=False) as client: while loop.time() < deadline: try: await client.get(url, timeout=1.0) @@ -1704,6 +1718,8 @@ async def run_dev_apps( mcp_port: int = 8000, dev_port: int = 8080, reload: bool = True, + host: str = "127.0.0.1", + log_panel: bool = True, ) -> None: """Start the full dev environment for a FastMCPApp server. @@ -1711,8 +1727,16 @@ async def run_dev_apps( on *dev_port* (with an /mcp proxy to the user's server), then opens the browser. """ - mcp_url = f"http://localhost:{mcp_port}/mcp" - dev_url = f"http://localhost:{dev_port}" + mcp_url = ( + f"http://{host}:{mcp_port}/mcp" + if ":" not in host + else f"http://[{host}]:{mcp_port}/mcp" + ) + dev_url = ( + f"http://{host}:{dev_port}" + if ":" not in host + else f"http://[{host}]:{dev_port}" + ) user_proc: asyncio.subprocess.Process | None = None @@ -1727,10 +1751,20 @@ async def run_dev_apps( (dev_port, "dev UI", "--dev-port"), ]: in_use = False - for family, addr in ( - (socket.AF_INET, ("127.0.0.1", port)), - (socket.AF_INET6, ("::1", port, 0, 0)), - ): + _targets = ( + ( + (socket.AF_INET, ("127.0.0.1", port)), + (socket.AF_INET6, ("::1", port, 0, 0)), + ) + if host == "127.0.0.1" + else ( + ((socket.AF_INET6, (host, port, 0, 0)),) + if ":" in host + else ((socket.AF_INET, (host, port)),) + ) + ) + + for family, addr in _targets: try: with socket.socket(family, socket.SOCK_STREAM) as s: if s.connect_ex(addr) == 0: @@ -1751,7 +1785,9 @@ async def run_dev_apps( # Start the server first so user_proc is assigned before anything # that might fail (e.g. npm fetch). This ensures the finally # cleanup can kill the subprocess even if the bundle fetch raises. - user_proc = await _start_user_server(server_spec, mcp_port, reload=reload) + user_proc = await _start_user_server( + server_spec, mcp_port, reload=reload, host=host + ) app_bridge_js, import_map_json = await _fetch_app_bridge_bundle( _EXT_APPS_VERSION, _MCP_SDK_VERSION ) @@ -1766,10 +1802,12 @@ async def run_dev_apps( logger.info(f"FastMCP dev UI at {dev_url}") - dev_app = _make_dev_app(mcp_url, app_bridge_js, import_map_tag, _MessageLog()) + dev_app = _make_dev_app( + mcp_url, app_bridge_js, import_map_tag, _MessageLog(), log_panel + ) config = uvicorn.Config( dev_app, - host="localhost", + host=host, port=dev_port, log_level="warning", ws="websockets-sansio", diff --git a/fastmcp_slim/fastmcp/cli/cli.py b/fastmcp_slim/fastmcp/cli/cli.py index 3c11fa35f..c0e6b97f7 100644 --- a/fastmcp_slim/fastmcp/cli/cli.py +++ b/fastmcp_slim/fastmcp/cli/cli.py @@ -359,6 +359,21 @@ async def apps( help="Auto-reload the MCP server on file changes", ), ] = True, + host: Annotated[ + str, + cyclopts.Parameter( + "--host", + help="Host to bind to", + ), + ] = "127.0.0.1", + log_panel: Annotated[ + bool, + cyclopts.Parameter( + "--log-panel", + negative="--no-log-panel", + help="Log panel feature in FastMCP dev UI", + ), + ] = True, ) -> None: """Preview a FastMCPApp UI in the browser. @@ -378,7 +393,14 @@ async def apps( from fastmcp.cli.apps_dev import run_dev_apps - await run_dev_apps(server_spec, mcp_port=mcp_port, dev_port=dev_port, reload=reload) + await run_dev_apps( + server_spec, + mcp_port=mcp_port, + dev_port=dev_port, + reload=reload, + host=host, + log_panel=log_panel, + ) @app.command diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 91a7a2ea7..e5c54995f 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -8,8 +8,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import ValidationError +from starlette.testclient import TestClient -from fastmcp.cli.cli import inspector, run +from fastmcp.cli.apps_dev import _make_dev_app, _MessageLog +from fastmcp.cli.cli import apps, inspector, run from fastmcp.cli.run import ( create_mcp_config_server, is_url, @@ -991,3 +993,157 @@ class TestInspectorModuleMode: # --module should be in the subprocess command cmd = mock_subprocess.call_args[0][0] assert "--module" in cmd + + +class TestRunDevApps: + """Test running dev apps with the run command.""" + + @pytest.mark.parametrize( + "host, expected_host", + [ + ("0.0.0.0", "0.0.0.0"), + ("127.0.0.1", "127.0.0.1"), + ], + ) + async def test_run_dev_apps_with_host(self, host, expected_host): + """Test run command can run a dev app with below new options for issue 4121. + - host option to support binding specific address other than localhost. + """ + mock_proc = MagicMock() + mock_proc.returncode = 0 + + mock_server = AsyncMock() + mock_server.install_signal_handlers = MagicMock() + mock_server.serve = AsyncMock() + + mock_uvicorn = MagicMock() + mock_uvicorn.Config = MagicMock() + mock_uvicorn.Server.return_value = mock_server + + mock_make_dev_app = MagicMock(return_value=MagicMock()) + mock_webbrowser_open = MagicMock() + + with ( + patch( + "fastmcp.cli.apps_dev._start_user_server", + new_callable=AsyncMock, + return_value=mock_proc, + ), + patch( + "fastmcp.cli.apps_dev._fetch_app_bridge_bundle", + new_callable=AsyncMock, + return_value=("js_content", "{}"), + ), + patch( + "fastmcp.cli.apps_dev._wait_for_server", + new_callable=AsyncMock, + return_value=True, + ), + patch("fastmcp.cli.apps_dev._make_dev_app", mock_make_dev_app), + patch("fastmcp.cli.apps_dev.uvicorn", mock_uvicorn), + patch("fastmcp.cli.apps_dev.webbrowser.open", mock_webbrowser_open), + patch("fastmcp.cli.apps_dev.asyncio.sleep", new_callable=AsyncMock), + patch("socket.socket"), + ): + await apps("server.py", host=host) + + make_dev_app_first_arg = mock_make_dev_app.call_args[0][0] + assert expected_host in make_dev_app_first_arg + + webbrowser_open_first_arg = mock_webbrowser_open.call_args[0][0] + assert expected_host in webbrowser_open_first_arg + + @pytest.mark.parametrize( + "log_panel, expected_log_panel", + [ + (True, True), + (False, False), + ], + ) + async def test_run_dev_apps_log_panel_propagation( + self, log_panel, expected_log_panel + ): + """Test run command can run a dev app with below new options for issue 4121. + - toggle log-panel option to hide log/debug message in normal cases. + + The test divided into two parts: + - first, verify if log_panel is correctly propagated to _make_dev_app. + - second, verify if _make_dev_app calls _inject_log_panel only when log_panel=True + + This test function implements the first part, and the second part is implemented in test_make_dev_app_log_panel_controls_inject + """ + + mock_proc = MagicMock() + mock_proc.returncode = 0 + + mock_server = AsyncMock() + mock_server.install_signal_handlers = MagicMock() + mock_server.serve = AsyncMock() + + mock_uvicorn = MagicMock() + mock_uvicorn.Config = MagicMock() + mock_uvicorn.Server.return_value = mock_server + + mock_make_dev_app = MagicMock(return_value=MagicMock()) + mock_webbrowser_open = MagicMock() + + with ( + patch( + "fastmcp.cli.apps_dev._start_user_server", + new_callable=AsyncMock, + return_value=mock_proc, + ), + patch( + "fastmcp.cli.apps_dev._fetch_app_bridge_bundle", + new_callable=AsyncMock, + return_value=("js_content", "{}"), + ), + patch( + "fastmcp.cli.apps_dev._wait_for_server", + new_callable=AsyncMock, + return_value=True, + ), + patch("fastmcp.cli.apps_dev._make_dev_app", mock_make_dev_app), + patch("fastmcp.cli.apps_dev.uvicorn", mock_uvicorn), + patch("fastmcp.cli.apps_dev.webbrowser.open", mock_webbrowser_open), + patch("fastmcp.cli.apps_dev.asyncio.sleep", new_callable=AsyncMock), + patch("socket.socket"), + ): + await apps("server.py", log_panel=log_panel) + + # log_panel is the 5th positional argument to _make_dev_app + actual_log_panel = mock_make_dev_app.call_args[0][4] + assert actual_log_panel is expected_log_panel + + @pytest.mark.parametrize("log_panel", [True, False]) + def test_make_dev_app_log_panel_controls_inject(self, log_panel): + """Test if _make_dev_app calls _inject_log_panel only when log_panel=True, regarding issue 4121: + - toggle log-panel option to hide log/debug message in normal cases. + + The test divided into two parts: + - first, verify if log_panel is correctly propagated to _make_dev_app. + - second, verify if _make_dev_app calls _inject_log_panel only when log_panel=True + + This test function implements the second part, and the first part is implemented in test_run_dev_apps_log_panel_propagation + """ + + mock_message_log = _MessageLog() + + with patch( + "fastmcp.cli.apps_dev._inject_log_panel", + return_value="injected", + ) as mock_inject: + starlette_app = _make_dev_app( + mcp_url="http://127.0.0.1:8000/mcp", + app_bridge_js="// js", + import_map_tag="", + message_log=mock_message_log, + log_panel=log_panel, + ) + client = TestClient(starlette_app, raise_server_exceptions=False) + client.get("/") + + if log_panel: + mock_inject.assert_called_once() + else: + mock_inject.assert_not_called()