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