feat(host-agent): mount /mcp + surface MCP status in console
This commit is contained in:
@@ -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 "<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
|
||||
|
||||
Reference in New Issue
Block a user