From ce2469616ed1ec14ab05500ea4bea03432ee4038 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 21 Jul 2026 15:09:41 +0800 Subject: [PATCH] feat(host-agent): mount /mcp + surface MCP status in console --- apps/device-host-agent/host_agent/web/app.py | 37 ++++- .../host_agent/web/templates/dashboard.html | 18 +++ apps/device-host-agent/tests/test_web_app.py | 138 ++++++++++++++++++ 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/apps/device-host-agent/host_agent/web/app.py b/apps/device-host-agent/host_agent/web/app.py index 6815795..2ce7a18 100644 --- a/apps/device-host-agent/host_agent/web/app.py +++ b/apps/device-host-agent/host_agent/web/app.py @@ -24,6 +24,8 @@ from host_agent.devices import register_local_device, unregister_local_device from host_agent.history import ConsoleHistoryStore from host_agent.identity import HostIdentityStore from host_agent.local_account import LocalAccountStore +from host_agent.mcp_lock import McpBusyTracker +from host_agent.mcp_token import McpTokenStore from host_agent.status import AgentStatusTracker from host_agent.web.auth import ( SessionManager, @@ -31,6 +33,7 @@ from host_agent.web.auth import ( attempt_login, change_password, ) +from host_agent.web.mcp_auth import BearerAuthMiddleware from storage.device_config import DeviceConfigStore from storage.task_metadata import TaskMetadataStore from storage.timeline import Timeline @@ -223,6 +226,9 @@ def create_console_app( metadata_store: TaskMetadataStore | None = None, timeline: Timeline | None = None, executor: AssignmentExecutor | None = None, + mcp_server: Any = None, + mcp_token_store: McpTokenStore | None = None, + mcp_busy_tracker: McpBusyTracker | None = None, ) -> FastAPI: app = FastAPI(title="Host Agent Console") cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS @@ -231,6 +237,18 @@ def create_console_app( if cancel_task is None and host_client is not None: cancel_task = host_client.cancel_task submission_available = submit_self_task is not None + mcp_mounted = mcp_server is not None and mcp_token_store is not None + if mcp_mounted: + from starlette.applications import Starlette + from starlette.middleware import Middleware + + mcp_asgi = mcp_server.streamable_http_app() + authed = Starlette( + routes=[], + middleware=[Middleware(BearerAuthMiddleware, token_store=mcp_token_store)], + ) + authed.router.mount("/", mcp_asgi) + app.mount("/mcp", authed) def _running_devices() -> list[dict[str, str]]: return [ @@ -340,6 +358,10 @@ def create_console_app( for d in manager.list_devices() ] texts = _dashboard_texts(snapshot=snapshot) + mcp_endpoint = "/mcp" if mcp_mounted else None + mcp_busy_devices = ( + mcp_busy_tracker.busy_device_ids() if mcp_busy_tracker is not None else [] + ) return _render( "dashboard.html", title="Status", @@ -347,6 +369,8 @@ def create_console_app( identity=identity, devices=devices, config=config, + mcp_endpoint=mcp_endpoint, + mcp_busy_devices=mcp_busy_devices, **texts, ) @@ -375,7 +399,18 @@ def create_console_app( } for device in manager.list_devices() ] - return JSONResponse({"status": snapshot, "devices": devices}) + return JSONResponse( + { + "status": snapshot, + "devices": devices, + "mcp_endpoint": "/mcp" if mcp_mounted else None, + "mcp_busy_devices": ( + mcp_busy_tracker.busy_device_ids() + if mcp_busy_tracker is not None + else [] + ), + } + ) @app.get("/devices", response_class=HTMLResponse) async def devices_page( diff --git a/apps/device-host-agent/host_agent/web/templates/dashboard.html b/apps/device-host-agent/host_agent/web/templates/dashboard.html index 8108d3c..7922c36 100644 --- a/apps/device-host-agent/host_agent/web/templates/dashboard.html +++ b/apps/device-host-agent/host_agent/web/templates/dashboard.html @@ -20,6 +20,24 @@
{{ assignment_text }}
{{ progress_text }}
+| MCP | +
+ {% if mcp_endpoint %}
+ endpoint {{ mcp_endpoint }};
+ {% if mcp_busy_devices %}busy: {{ mcp_busy_devices|join(", ") }}{% else %}idle{% endif %}
+ {% else %}
+ not configured
+ {% endif %}
+ |
+
| MCP | " in text + assert "/mcp" in text + assert "phone-1" in text + + +def test_dashboard_renders_mcp_not_configured_when_components_missing( + tmp_path, +) -> None: + client, _ = _build_client(tmp_path) + _login(client) + + response = client.get("/") + assert response.status_code == 200 + text = response.text + assert "MCP | " in text + assert "not configured" in text + + +def test_mcp_endpoint_unauthorized_without_bearer_token(tmp_path) -> None: + server, token_store, tracker = _build_mcp_components(tmp_path) + client, _ = _build_client( + tmp_path, + mcp_server=server, + mcp_token_store=token_store, + mcp_busy_tracker=tracker, + ) + resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1}) + assert resp.status_code == 401 + + +def test_mcp_endpoint_rejects_invalid_bearer_token(tmp_path) -> None: + server, token_store, tracker = _build_mcp_components(tmp_path) + client, _ = _build_client( + tmp_path, + mcp_server=server, + mcp_token_store=token_store, + mcp_busy_tracker=tracker, + ) + resp = client.post( + "/mcp/", + headers={"Authorization": "Bearer not-the-real-token"}, + json={"jsonrpc": "2.0", "method": "ping", "id": 1}, + ) + assert resp.status_code == 401