mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-26 15:34:18 +02:00
feat: new options --host and --no-log-panel | --log-panel to cli dev apps (#4123)
This commit is contained in:
parent
344a4f81b5
commit
7fddab52a9
3 changed files with 231 additions and 15 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue