feat(host-agent): mount /mcp + surface MCP status in console

This commit is contained in:
2026-07-21 15:09:41 +08:00
parent dcb4798408
commit ce2469616e
3 changed files with 192 additions and 1 deletions
+36 -1
View File
@@ -24,6 +24,8 @@ from host_agent.devices import register_local_device, unregister_local_device
from host_agent.history import ConsoleHistoryStore from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore from host_agent.identity import HostIdentityStore
from host_agent.local_account import LocalAccountStore 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.status import AgentStatusTracker
from host_agent.web.auth import ( from host_agent.web.auth import (
SessionManager, SessionManager,
@@ -31,6 +33,7 @@ from host_agent.web.auth import (
attempt_login, attempt_login,
change_password, change_password,
) )
from host_agent.web.mcp_auth import BearerAuthMiddleware
from storage.device_config import DeviceConfigStore from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline from storage.timeline import Timeline
@@ -223,6 +226,9 @@ def create_console_app(
metadata_store: TaskMetadataStore | None = None, metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None, timeline: Timeline | None = None,
executor: AssignmentExecutor | None = None, executor: AssignmentExecutor | None = None,
mcp_server: Any = None,
mcp_token_store: McpTokenStore | None = None,
mcp_busy_tracker: McpBusyTracker | None = None,
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title="Host Agent Console") app = FastAPI(title="Host Agent Console")
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS 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: if cancel_task is None and host_client is not None:
cancel_task = host_client.cancel_task cancel_task = host_client.cancel_task
submission_available = submit_self_task is not None 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]]: def _running_devices() -> list[dict[str, str]]:
return [ return [
@@ -340,6 +358,10 @@ def create_console_app(
for d in manager.list_devices() for d in manager.list_devices()
] ]
texts = _dashboard_texts(snapshot=snapshot) 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( return _render(
"dashboard.html", "dashboard.html",
title="Status", title="Status",
@@ -347,6 +369,8 @@ def create_console_app(
identity=identity, identity=identity,
devices=devices, devices=devices,
config=config, config=config,
mcp_endpoint=mcp_endpoint,
mcp_busy_devices=mcp_busy_devices,
**texts, **texts,
) )
@@ -375,7 +399,18 @@ def create_console_app(
} }
for device in manager.list_devices() 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) @app.get("/devices", response_class=HTMLResponse)
async def devices_page( async def devices_page(
@@ -20,6 +20,24 @@
<p id="current-assignment">{{ assignment_text }}</p> <p id="current-assignment">{{ assignment_text }}</p>
<p id="current-progress">{{ progress_text }}</p> <p id="current-progress">{{ progress_text }}</p>
</section> </section>
<section>
<h2>MCP</h2>
<table>
<tbody>
<tr>
<td>MCP</td>
<td>
{% if mcp_endpoint %}
endpoint <code>{{ mcp_endpoint }}</code>;
{% if mcp_busy_devices %}busy: {{ mcp_busy_devices|join(", ") }}{% else %}idle{% endif %}
{% else %}
not configured
{% endif %}
</td>
</tr>
</tbody>
</table>
</section>
<section> <section>
<h2>Devices</h2> <h2>Devices</h2>
<table> <table>
@@ -6,6 +6,7 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mcp.server.fastmcp import FastMCP
from cloud.internal_api.models import AssignmentModel from cloud.internal_api.models import AssignmentModel
from core.models import Task from core.models import Task
@@ -15,9 +16,12 @@ from host_agent.config import HostAgentConfig
from host_agent.history import ConsoleHistoryStore from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore from host_agent.identity import HostIdentityStore
from host_agent.local_account import LocalAccountStore 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.status import AgentStatusTracker
from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app
from host_agent.web.auth import SessionManager from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server
from storage.device_config import DeviceConfigStore from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore from storage.task_metadata import TaskMetadataStore
@@ -34,6 +38,9 @@ def _build_client(
submit_self_task: TaskSubmissionCallable | None = None, submit_self_task: TaskSubmissionCallable | None = None,
cancel_task: TaskCancellationCallable | None = None, cancel_task: TaskCancellationCallable | None = None,
include_metadata_store: bool = True, 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]: ) -> tuple[TestClient, dict]:
config = HostAgentConfig( config = HostAgentConfig(
control_plane_url="https://control.example", control_plane_url="https://control.example",
@@ -68,6 +75,9 @@ def _build_client(
submit_self_task=submit_self_task, submit_self_task=submit_self_task,
cancel_task=cancel_task, cancel_task=cancel_task,
metadata_store=metadata_store, metadata_store=metadata_store,
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
) )
client = TestClient(app) client = TestClient(app)
context = { context = {
@@ -959,3 +969,131 @@ def test_cancel_task_without_csrf_token_is_rejected(tmp_path) -> None:
assert response.status_code == 403 assert response.status_code == 403
assert captured == {} 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 "<td>MCP</td>" 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 "<td>MCP</td>" 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