feat: new options --host and --no-log-panel | --log-panel to cli dev apps (#4123)

This commit is contained in:
itaru2622 2026-05-20 22:37:32 +09:00 committed by GitHub
commit 7fddab52a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 231 additions and 15 deletions

View file

@ -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",

View file

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

View file

@ -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="<html>injected</html>",
) 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()