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

+ + + + + + + +
MCP + {% if mcp_endpoint %} + endpoint {{ mcp_endpoint }}; + {% if mcp_busy_devices %}busy: {{ mcp_busy_devices|join(", ") }}{% else %}idle{% endif %} + {% else %} + not configured + {% endif %} +
+

Devices

diff --git a/apps/device-host-agent/tests/test_web_app.py b/apps/device-host-agent/tests/test_web_app.py index e609ac8..36062a7 100644 --- a/apps/device-host-agent/tests/test_web_app.py +++ b/apps/device-host-agent/tests/test_web_app.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime from typing import Any from fastapi.testclient import TestClient +from mcp.server.fastmcp import FastMCP from cloud.internal_api.models import AssignmentModel from core.models import Task @@ -15,9 +16,12 @@ from host_agent.config import HostAgentConfig 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.app import SESSION_COOKIE_NAME, create_console_app from host_agent.web.auth import SessionManager +from host_agent.web.mcp import build_mcp_server from storage.device_config import DeviceConfigStore from storage.task_metadata import TaskMetadataStore @@ -34,6 +38,9 @@ def _build_client( submit_self_task: TaskSubmissionCallable | None = None, cancel_task: TaskCancellationCallable | None = None, include_metadata_store: bool = True, + mcp_server: FastMCP | None = None, + mcp_token_store: McpTokenStore | None = None, + mcp_busy_tracker: McpBusyTracker | None = None, ) -> tuple[TestClient, dict]: config = HostAgentConfig( control_plane_url="https://control.example", @@ -68,6 +75,9 @@ def _build_client( submit_self_task=submit_self_task, cancel_task=cancel_task, metadata_store=metadata_store, + mcp_server=mcp_server, + mcp_token_store=mcp_token_store, + mcp_busy_tracker=mcp_busy_tracker, ) client = TestClient(app) context = { @@ -959,3 +969,131 @@ def test_cancel_task_without_csrf_token_is_rejected(tmp_path) -> None: assert response.status_code == 403 assert captured == {} + + +# --------------------------------------------------------------------------- +# MCP mount + /api/status fields + dashboard row +# --------------------------------------------------------------------------- + + +def _build_mcp_components(tmp_path) -> tuple[FastMCP, McpTokenStore, McpBusyTracker]: + manager = DeviceManager() + status_tracker = AgentStatusTracker() + tracker = McpBusyTracker() + token_store = McpTokenStore(tmp_path / "host_mcp_token.json") + server = build_mcp_server( + manager=manager, + mcp_busy_tracker=tracker, + status_tracker=status_tracker, + ) + return server, token_store, tracker + + +def test_console_app_mounts_mcp_when_all_components_provided(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, + ) + # Without auth, the bearer middleware should respond 401 — not 404. + resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1}) + assert resp.status_code != 404 + + +def test_console_app_does_not_mount_mcp_when_components_missing(tmp_path) -> None: + client, _ = _build_client(tmp_path) + resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1}) + assert resp.status_code == 404 + + +def test_api_status_includes_mcp_busy_devices(tmp_path) -> None: + server, token_store, tracker = _build_mcp_components(tmp_path) + # Acquire a lease without going through HTTP — tracker exposes a direct API. + tracker.acquire("phone-1", "test-session") + client, _ = _build_client( + tmp_path, + mcp_server=server, + mcp_token_store=token_store, + mcp_busy_tracker=tracker, + ) + _login(client) + + response = client.get("/api/status") + assert response.status_code == 200 + body = response.json() + assert "mcp_busy_devices" in body + assert "phone-1" in body["mcp_busy_devices"] + assert body["mcp_endpoint"] == "/mcp" + + +def test_api_status_omits_mcp_fields_when_components_missing(tmp_path) -> None: + client, _ = _build_client(tmp_path) + _login(client) + + response = client.get("/api/status") + assert response.status_code == 200 + body = response.json() + assert body["mcp_busy_devices"] == [] + assert body["mcp_endpoint"] is None + + +def test_dashboard_renders_mcp_status_row(tmp_path) -> None: + server, token_store, tracker = _build_mcp_components(tmp_path) + tracker.acquire("phone-1", "test-session") + client, _ = _build_client( + tmp_path, + mcp_server=server, + mcp_token_store=token_store, + mcp_busy_tracker=tracker, + ) + _login(client) + + response = client.get("/") + assert response.status_code == 200 + text = response.text + assert "" 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 "" 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
MCPMCP