15-task TDD plan implementing the spec committed in 3b62195. Covers
the four new host-agent modules (mcp_token, mcp_lock, web/mcp_auth,
web/mcp), cloud heartbeat + scheduler coordination, console mount
wiring, CLI subcommand, and documentation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
95 KiB
Host-Agent MCP Server Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Mount a Streamable HTTP MCP server inside the host-agent process so external MCP clients (Hermes Agent) can drive devices directly, coexisting with the Cloud Control Plane worker path.
Architecture: Reuse the existing host-agent console FastAPI + uvicorn on port 8765. Add app.mount("/mcp", auth_wrapped(FastMCP.streamable_http_app())). The FastMCP server wraps api.mcp.tool_handlers(manager=...) with a busy-check + lazy-lock decorator. Per-device session-level locks with 60s TTL. Cloud scheduler learns about MCP-busy devices via a new heartbeat field.
Tech Stack: Python 3.13, FastAPI, uvicorn, FastMCP (mcp>=1.28,<2), Pydantic v2, pytest, secrets/tempfile/os.replace for atomic token files, threading.Lock for in-process locks.
Global Constraints
- Python:
>=3.13,<3.14(workspace pin, seeapps/device-host-agent/pyproject.toml) - Package boundary:
runtime/andapi/packages MUST NOT importcloudorhost_agent. Enforced byapps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns. tool_handlerssignature: After Task 2,tool_handlers(*, manager: DeviceManager)is required (no None default). The singleapi/mcp.py::tool_handlerswrapper in this repo MUST get an explicit manager — never fall back toDEFAULT_MANAGER.- Network binding: MCP server inherits console's loopback-only bind.
HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=trueis the only escape hatch (existing config). - Token storage:
tempfile.NamedTemporaryFile+os.replaceatomic rename; file mode0o600on POSIX; JSON schema{"version": 1, "token": "...", "created_at": "<isoformat>"}. - Lock semantics: per-device, session-level, lazy acquire on first device-touching tool call, renew on every call, release on session end, TTL sweep on read. MVP callers all use try-acquire (
acquire()returning False on conflict);wait_until_usableis implemented + unit-tested but not invoked by any production caller. - Test commands:
- Single package:
uv run --package device-host-agent pytest apps/device-host-agent/tests/test_<file>.py -v - Cloud package:
uv run --package device-cloud-platform pytest packages/cloud-platform/tests/test_<file>.py -v - Full non-integration:
uv run --all-packages pytest -m "not integration" - Ruff:
uv run --with ruff ruff check <paths>thenuv run --with ruff ruff format --check <paths> - Compile:
uv run --all-packages python -m compileall <paths>
- Single package:
File Structure
New files
apps/device-host-agent/host_agent/mcp_token.py—McpTokenStore,McpToken,McpTokenStoreErrorapps/device-host-agent/host_agent/mcp_lock.py—McpBusyTracker,McpDeviceLeaseapps/device-host-agent/host_agent/web/mcp_auth.py—BearerAuthMiddlewareapps/device-host-agent/host_agent/web/mcp.py—build_mcp_server(*, manager, mcp_busy_tracker, status_tracker) -> FastMCPapps/device-host-agent/tests/test_mcp_token.pyapps/device-host-agent/tests/test_mcp_lock.pyapps/device-host-agent/tests/test_mcp_auth.pyapps/device-host-agent/tests/test_web_mcp.pydocs/MCP_INTEGRATION.md
Modified files
apps/device-host-agent/pyproject.toml— addmcp>=1.28,<2direct depapi/mcp.py—tool_handlers(*, manager: DeviceManager)required-arg tightenapps/device-host-agent/host_agent/app.py— construct new components increate_application(), pass tocreate_console_app()apps/device-host-agent/host_agent/web/app.py—create_console_app(...)accepts new params, mounts/mcp, addsmcp_busy_devicesto/api/statusapps/device-host-agent/host_agent/web/templates/dashboard.html— one MCP status rowapps/device-host-agent/host_agent/cli.py— newmcp-tokensubcommandapps/device-host-agent/host_agent/heartbeat.py—HeartbeatSynchronizeraccepts optionalmcp_busy_tracker,build_device_snapshotaddsmcp_busy_device_idsapps/device-host-agent/host_agent/client.py—heartbeat()accepts and sendsmcp_busy_device_idsapps/device-host-agent/host_agent/assignment.py—AssignmentExecutoraccepts optionalmcp_busy_tracker;execute()does fail-fast checkpackages/cloud-platform/cloud/internal_api/models.py—HeartbeatRequest.mcp_busy_device_ids: list[str] = []packages/cloud-platform/cloud/pool.py—PooledDevice.mcp_busy: bool = False;sync_host_devices()acceptsmcp_busy_device_ids;_to_pooled()sets flagpackages/cloud-platform/cloud/scheduler.py—_matches()rejectsdevice.mcp_busypackages/cloud-platform/cloud/internal_api/api.py— heartbeat handler passesmcp_busy_device_idstopool.sync_host_devices()docs/MACOS_IPHONE_SETUP.md— add "MCP Integration" section
Task 1: Add mcp as direct dependency of device-host-agent
Files:
- Modify:
apps/device-host-agent/pyproject.toml:6-14
Interfaces:
-
Produces: device-host-agent now declares
mcp>=1.28,<2as a direct dep (it was previously transitively available via device-agent-runtime). -
Step 1: Read current pyproject.toml
Run: cat apps/device-host-agent/pyproject.toml
Expected output: see lines 6-14 are the dependencies = [...] list with 7 entries (device-agent-runtime, device-cloud-platform, fastapi, filelock, httpx, jinja2, uvicorn[standard]).
- Step 2: Add
mcp>=1.28,<2to dependencies
Edit apps/device-host-agent/pyproject.toml. The final dependencies block should be:
dependencies = [
"device-agent-runtime==0.1.0",
"device-cloud-platform==0.1.0",
"fastapi>=0.115.0",
"filelock>=3.0",
"httpx>=0.27.0",
"jinja2>=3.1",
"mcp>=1.28,<2",
"uvicorn[standard]>=0.30.0",
]
(Note: keep entries alphabetically ordered per existing convention.)
- Step 3: Re-lock the workspace
Run: uv lock
Expected: uv reports "Resolved X packages" and updates uv.lock with mcp listed under device-host-agent's dependencies. mcp version remains 1.28.1 (already in lock as transitive — now dual-listed as direct).
- Step 4: Verify sync works
Run: uv sync --locked --all-packages
Expected: no error; "Resolved X packages" + "Installed X packages" or "Audited X packages" with no diffs.
- Step 5: Verify import works in device-host-agent context
Run: uv run --package device-host-agent python -c "from mcp.server.fastmcp import FastMCP; print('ok')"
Expected output: ok
- Step 6: Commit
git add apps/device-host-agent/pyproject.toml uv.lock
git commit -m "build(host-agent): add mcp as direct dependency"
Task 2: Tighten tool_handlers signature (D12)
Eliminate the silent fallback to DEFAULT_MANAGER that caused the DeviceNotFoundError incident (see apps/device-host-agent/host_agent/execution.py regression tests for the same trap in task runners).
Files:
- Modify:
api/mcp.py:18-95 - Modify:
api/mcp.py:98-110(create_mcp_servernow must receive non-None manager or raise) - Test:
tests/test_mcp.py(extend)
Interfaces:
-
Produces:
tool_handlers(*, manager: DeviceManager) -> dict[str, Callable[..., Any]](manager is keyword-required, no default). -
Produces:
create_mcp_server(*, manager: DeviceManager, ...)raisesValueErrorifmanager is None. -
Step 1: Write failing test that
tool_handlers()without manager raises
Append to tests/test_mcp.py:
def test_tool_handlers_requires_manager() -> None:
"""D12: tool_handlers must not silently fall back to DEFAULT_MANAGER."""
from api.mcp import tool_handlers
import pytest
with pytest.raises(TypeError):
tool_handlers() # type: ignore[call-arg]
- Step 2: Run test to verify it fails (signature still allows None)
Run: uv run --all-packages pytest tests/test_mcp.py::test_tool_handlers_requires_manager -v
Expected: FAIL — currently tool_handlers(manager=None) succeeds because None is the default.
- Step 3: Tighten signature in
api/mcp.py
Replace api/mcp.py:18-22 (the existing def tool_handlers(...) declaration) with:
def tool_handlers(
*,
manager: DeviceManager,
) -> dict[str, Callable[..., Any]]:
Remove the line device_manager = manager or DEFAULT_MANAGER (was line 22 in original) and rename all uses of device_manager inside the function body to manager. The DEFAULT_MANAGER import on line 6 (from device.manager import DeviceManager, DEFAULT_MANAGER) becomes unused — remove DEFAULT_MANAGER from the import, keeping only DeviceManager.
In create_mcp_server (around line 98-110), if manager is None: raise ValueError("create_mcp_server requires a non-None manager") before constructing handlers. Update the signature manager: DeviceManager | None = None to manager: DeviceManager (required keyword).
- Step 4: Run test to verify it passes
Run: uv run --all-packages pytest tests/test_mcp.py::test_tool_handlers_requires_manager -v
Expected: PASS.
- Step 5: Verify all existing callers still work
The grep in spec §10 Q1 found these callers, all of which already pass manager=manager:
api/mcp.py:110— internal call insidecreate_mcp_servertests/test_mcp.py:13, 56tests/test_skill_catalog_e2e.py:287
Run: uv run --all-packages pytest tests/test_mcp.py tests/test_skill_catalog_e2e.py -v
Expected: all PASS.
- Step 6: Verify
test_runtime_owned_packages_do_not_import_host_or_cloud_concernsstill passes
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns -v
Expected: PASS (this task doesn't change package boundaries).
- Step 7: Commit
git add api/mcp.py tests/test_mcp.py
git commit -m "refactor(api): make tool_handlers require a DeviceManager
Eliminates the silent fallback to DEFAULT_MANAGER that produced the
DeviceNotFoundError incident. All existing callers already pass
manager explicitly."
Task 3: McpTokenStore — token file persistence
Files:
- Create:
apps/device-host-agent/host_agent/mcp_token.py - Test:
apps/device-host-agent/tests/test_mcp_token.py
Interfaces:
-
Produces:
McpTokenStore(path: Path, *, now: Callable[[], datetime] | None = None)with methods:load_or_create() -> McpToken— atomic; reads if exists, else generates + writesverify(presented: str) -> bool—hmac.compare_digestagainst loaded token
-
Produces:
McpToken(version: int, token: str, created_at: datetime)(frozen dataclass) -
Produces:
McpTokenStoreError(RuntimeError) -
Step 1: Write failing tests for
McpTokenStore
Create apps/device-host-agent/tests/test_mcp_token.py:
from __future__ import annotations
import json
import os
import stat
import sys
from datetime import datetime, timezone
from pathlib import Path
import pytest
from host_agent.mcp_token import McpToken, McpTokenStore, McpTokenStoreError
def test_load_or_create_generates_when_missing(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create()
assert token.version == 1
assert len(token.token) >= 40 # secrets.token_urlsafe(32) -> ~43 chars
assert isinstance(token.created_at, datetime)
# File now exists.
assert (tmp_path / "host_mcp_token.json").exists()
def test_load_or_create_is_idempotent(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
first = store.load_or_create()
second = McpTokenStore(tmp_path / "host_mcp_token.json").load_or_create()
assert first.token == second.token
def test_load_or_create_writes_json_schema(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
McpTokenStore(path).load_or_create()
data = json.loads(path.read_text())
assert set(data) == {"version", "token", "created_at"}
assert data["version"] == 1
assert isinstance(data["token"], str)
# created_at is ISO 8601.
datetime.fromisoformat(data["created_at"])
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perms only")
def test_load_or_create_sets_posix_permissions(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
McpTokenStore(path).load_or_create()
mode = stat.S_IMODE(os.fstat(os.open(path, os.O_RDONLY)).st_mode)
assert mode == 0o600
def test_verify_accepts_correct_token(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create()
assert store.verify(token.token) is True
def test_verify_rejects_wrong_token(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
store.load_or_create()
assert store.verify("wrong") is False
def test_load_or_create_raises_on_corrupt_json(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
path.write_text("{not valid json")
with pytest.raises(McpTokenStoreError):
McpTokenStore(path).load_or_create()
def test_load_or_create_raises_on_unwritable_dir(tmp_path: Path) -> None:
unwritable = tmp_path / "ro"
unwritable.mkdir()
os.chmod(unwritable, 0o500) # r-x for owner
try:
with pytest.raises(McpTokenStoreError):
McpTokenStore(unwritable / "host_mcp_token.json").load_or_create()
finally:
os.chmod(unwritable, 0o700) # restore so cleanup works
def test_load_or_create_concurrent_calls_do_not_corrupt(
tmp_path: Path,
) -> None:
"""Two store instances racing to create: both end up reading the same token."""
import threading
path = tmp_path / "host_mcp_token.json"
results: list[McpToken] = []
barrier = threading.Barrier(2)
def worker() -> None:
barrier.wait()
store = McpTokenStore(path)
results.append(store.load_or_create())
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results) == 2
assert results[0].token == results[1].token
- Step 2: Run tests to verify they fail
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_mcp_token.py -v
Expected: FAIL — host_agent.mcp_token doesn't exist.
- Step 3: Implement
McpTokenStore
Create apps/device-host-agent/host_agent/mcp_token.py:
"""Bearer-token persistence for the host-agent MCP server.
The token is generated on first start and persisted to a JSON file with
0o600 permissions (POSIX) alongside the host identity. Rotation = delete
the file and restart host-agent.
"""
from __future__ import annotations
import json
import os
import secrets
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_TOKEN_BYTES = 32
class McpTokenStoreError(RuntimeError):
"""Raised when the MCP token file cannot be read or written."""
@dataclass(frozen=True)
class McpToken:
version: int
token: str
created_at: datetime
class McpTokenStore:
def __init__(
self,
path: Path,
*,
now: Callable[[], datetime] | None = None,
) -> None:
self._path = Path(path)
self._now = now or (lambda: datetime.now(UTC))
def load_or_create(self) -> McpToken:
if self._path.exists():
return self._read_existing()
return self._generate_and_write()
def verify(self, presented: str) -> bool:
try:
token = self.load_or_create()
except McpTokenStoreError:
return False
import hmac
return hmac.compare_digest(token.token, presented)
def _read_existing(self) -> McpToken:
try:
data = json.loads(self._path.read_text())
except (OSError, json.JSONDecodeError) as exc:
raise McpTokenStoreError(
f"cannot read MCP token file {self._path}: {exc}"
) from exc
if not isinstance(data, dict):
raise McpTokenStoreError("MCP token file is not a JSON object")
try:
return McpToken(
version=int(data["version"]),
token=str(data["token"]),
created_at=datetime.fromisoformat(str(data["created_at"])),
)
except (KeyError, TypeError, ValueError) as exc:
raise McpTokenStoreError(
f"MCP token file schema invalid: {exc}"
) from exc
def _generate_and_write(self) -> McpToken:
token = McpToken(
version=1,
token=secrets.token_urlsafe(_TOKEN_BYTES),
created_at=self._now(),
)
payload = {
"version": token.version,
"token": token.token,
"created_at": token.created_at.isoformat(),
}
try:
self._atomic_write(json.dumps(payload, indent=2))
except OSError as exc:
raise McpTokenStoreError(
f"cannot write MCP token file {self._path}: {exc}"
) from exc
return token
def _atomic_write(self, content: str) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
# Atomic on POSIX; on Windows os.replace is also atomic per docs.
fd, tmp_name = tempfile.mkstemp(
prefix=".host_mcp_token.",
suffix=".tmp",
dir=str(self._path.parent),
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, self._path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
- Step 4: Run tests to verify they pass
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_mcp_token.py -v
Expected: all 8 tests PASS (Windows skips the POSIX perm test).
- Step 5: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/mcp_token.py apps/device-host-agent/tests/test_mcp_token.py
Expected: clean.
- Step 6: Commit
git add apps/device-host-agent/host_agent/mcp_token.py apps/device-host-agent/tests/test_mcp_token.py
git commit -m "feat(host-agent): add McpTokenStore for MCP bearer token"
Task 4: McpBusyTracker — per-device session-level lock with TTL
Files:
- Create:
apps/device-host-agent/host_agent/mcp_lock.py - Test:
apps/device-host-agent/tests/test_mcp_lock.py
Interfaces:
-
Produces:
McpBusyTracker(*, ttl_seconds: float = 60.0, now: Callable[[], datetime] | None = None)with methods:acquire(device_id: str, session_id: str) -> boolrenew(device_id: str, session_id: str) -> boolrelease(session_id: str) -> list[str]— returns freed device_idsrelease_device(device_id: str, session_id: str) -> boolbusy_device_ids() -> list[str]— lazy-sweep expired leasessnapshot() -> list[McpDeviceLease]wait_until_usable(device_id, session_id, *, timeout, poll_interval=1.0, cloud_busy_check=None) -> bool— atomic acquire on success
-
Produces:
McpDeviceLease(device_id: str, session_id: str, acquired_at: datetime, last_seen_at: datetime)(frozen) -
Step 1: Write failing tests
Create apps/device-host-agent/tests/test_mcp_lock.py:
from __future__ import annotations
import threading
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from host_agent.mcp_lock import McpBusyTracker, McpDeviceLease
def _tracker_with_now() -> tuple[McpBusyTracker, list[datetime]]:
times: list[datetime] = []
def now() -> datetime:
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
tracker = McpBusyTracker(ttl_seconds=60.0, now=now)
return tracker, times
def test_acquire_succeeds_on_empty() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
assert "phone-1" in tracker.busy_device_ids()
def test_acquire_fails_when_held_by_other_session() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
assert tracker.acquire("phone-1", "sess-b") is False
def test_acquire_is_idempotent_for_same_session() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
# Same session re-acquiring is allowed (acts as renew).
assert tracker.acquire("phone-1", "sess-a") is True
def test_renew_refreshes_last_seen() -> None:
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
initial = tracker.snapshot()[0]
times.append(datetime(2026, 1, 1, 12, 0, 30, tzinfo=UTC))
assert tracker.renew("phone-1", "sess-a") is True
refreshed = tracker.snapshot()[0]
assert refreshed.last_seen_at > initial.last_seen_at
def test_renew_fails_when_held_by_other() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
assert tracker.renew("phone-1", "sess-b") is False
def test_release_returns_freed_device_ids() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-2", "sess-a")
freed = tracker.release("sess-a")
assert sorted(freed) == ["phone-1", "phone-2"]
assert tracker.busy_device_ids() == []
def test_release_only_frees_caller_session() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-1", "sess-b") # fails
freed = tracker.release("sess-b")
assert freed == []
assert "phone-1" in tracker.busy_device_ids()
def test_ttl_sweeps_expired_leases() -> None:
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
# Advance past TTL without renew.
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # 61s later
assert tracker.busy_device_ids() == []
def test_renew_after_ttl_tolerates_same_session() -> None:
"""Scene 10: lease expired but session_id matches -> re-acquire."""
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # expired
# renew from the same session should succeed (re-acquire).
assert tracker.renew("phone-1", "sess-a") is True
assert "phone-1" in tracker.busy_device_ids()
def test_snapshot_matches_busy_device_ids() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-2", "sess-a")
snap = tracker.snapshot()
assert {lease.device_id for lease in snap} == set(tracker.busy_device_ids())
def test_wait_until_usable_succeeds_when_free() -> None:
tracker, _ = _tracker_with_now()
ok = tracker.wait_until_usable(
"phone-1", "sess-a", timeout=1.0, poll_interval=0.01
)
assert ok is True
assert "phone-1" in tracker.busy_device_ids()
def test_wait_until_usable_returns_false_on_timeout() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
ok = tracker.wait_until_usable(
"phone-1", "sess-b", timeout=0.1, poll_interval=0.02
)
assert ok is False
def test_wait_until_usable_blocks_then_succeeds_when_released() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
def releaser() -> None:
import time
time.sleep(0.05)
tracker.release("sess-a")
t = threading.Thread(target=releaser)
t.start()
try:
ok = tracker.wait_until_usable(
"phone-1", "sess-b", timeout=2.0, poll_interval=0.02
)
assert ok is True
finally:
t.join()
def test_wait_until_usable_blocks_then_fails_when_cloud_remains_busy() -> None:
tracker, _ = _tracker_with_now()
ok = tracker.wait_until_usable(
"phone-1",
"sess-a",
timeout=0.1,
poll_interval=0.02,
cloud_busy_check=lambda: True,
)
assert ok is False
assert tracker.busy_device_ids() == []
- Step 2: Run tests to verify they fail
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_mcp_lock.py -v
Expected: FAIL — host_agent.mcp_lock doesn't exist.
- Step 3: Implement
McpBusyTracker
Create apps/device-host-agent/host_agent/mcp_lock.py:
"""Per-device MCP session-level busy tracker.
The cloud-side assignment path and the MCP-driven path both drive devices
through the same in-process ``DeviceManager``. This tracker records which
devices are currently held by an MCP session so that:
- MCP tool calls against a device held by another session (or by a cloud
assignment — checked separately by the caller via ``AgentStatusTracker``)
can fail fast with a busy error.
- The heartbeat payload can advertise ``mcp_busy_device_ids`` so the cloud
scheduler won't dispatch conflicting assignments to the same device.
Leases expire ``ttl_seconds`` after the last ``renew()`` call (set on every
tool call from the holding session). Expired leases are lazy-swept on read.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from threading import Lock
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
class McpDeviceLease:
device_id: str
session_id: str
acquired_at: datetime
last_seen_at: datetime
class McpBusyTracker:
def __init__(
self,
*,
ttl_seconds: float = 60.0,
now: Callable[[], datetime] | None = None,
) -> None:
self._ttl = float(ttl_seconds)
self._now = now or (lambda: datetime.now(UTC))
self._lock = Lock()
# device_id -> McpDeviceLease
self._leases: dict[str, McpDeviceLease] = {}
def acquire(self, device_id: str, session_id: str) -> bool:
with self._lock:
self._sweep_locked()
existing = self._leases.get(device_id)
if existing is not None and existing.session_id != session_id:
return False
now = self._now()
lease = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=(
existing.acquired_at if existing is not None else now
),
last_seen_at=now,
)
self._leases[device_id] = lease
return True
def renew(self, device_id: str, session_id: str) -> bool:
with self._lock:
self._sweep_locked()
existing = self._leases.get(device_id)
# Tolerate boundary: lease may have been swept, but if the caller
# is the legitimate previous holder, re-acquire on their behalf.
if existing is None:
now = self._now()
self._leases[device_id] = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=now,
last_seen_at=now,
)
return True
if existing.session_id != session_id:
return False
self._leases[device_id] = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=existing.acquired_at,
last_seen_at=self._now(),
)
return True
def release(self, session_id: str) -> list[str]:
with self._lock:
freed = [
device_id
for device_id, lease in self._leases.items()
if lease.session_id == session_id
]
for device_id in freed:
del self._leases[device_id]
return freed
def release_device(self, device_id: str, session_id: str) -> bool:
with self._lock:
existing = self._leases.get(device_id)
if existing is None or existing.session_id != session_id:
return False
del self._leases[device_id]
return True
def busy_device_ids(self) -> list[str]:
with self._lock:
self._sweep_locked()
return sorted(self._leases)
def snapshot(self) -> list[McpDeviceLease]:
with self._lock:
self._sweep_locked()
return sorted(self._leases.values(), key=lambda l: l.device_id)
def wait_until_usable(
self,
device_id: str,
session_id: str,
*,
timeout: float,
poll_interval: float = 1.0,
cloud_busy_check: Callable[[], bool] | None = None,
) -> bool:
"""Block until ``device_id`` is acquirable by ``session_id`` or timeout.
Reserved capability. MVP callers use try-acquire (``acquire`` -> False
means busy). This method exists for future wiring where the cloud
assignment path or an explicit MCP tool may opt to wait.
"""
deadline = time.monotonic() + timeout
while True:
cloud_busy = cloud_busy_check() if cloud_busy_check else False
if not cloud_busy:
if self.acquire(device_id, session_id):
return True
if time.monotonic() >= deadline:
return False
remaining = deadline - time.monotonic()
time.sleep(max(0.0, min(poll_interval, remaining)))
def _sweep_locked(self) -> None:
"""Caller holds ``self._lock``. Drops leases past their TTL."""
cutoff = self._now()
expired = [
device_id
for device_id, lease in self._leases.items()
if (cutoff - lease.last_seen_at).total_seconds() > self._ttl
]
for device_id in expired:
del self._leases[device_id]
- Step 4: Run tests to verify they pass
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_mcp_lock.py -v
Expected: all 14 tests PASS.
- Step 5: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/mcp_lock.py apps/device-host-agent/tests/test_mcp_lock.py
Expected: clean.
- Step 6: Commit
git add apps/device-host-agent/host_agent/mcp_lock.py apps/device-host-agent/tests/test_mcp_lock.py
git commit -m "feat(host-agent): add McpBusyTracker for per-device session locks"
Task 5: BearerAuthMiddleware
Files:
- Create:
apps/device-host-agent/host_agent/web/mcp_auth.py - Test:
apps/device-host-agent/tests/test_mcp_auth.py
Interfaces:
-
Produces:
BearerAuthMiddleware(app: ASGIApp, token_store: McpTokenStore)— StarletteBaseHTTPMiddlewaresubclass. -
Behavior: 401 +
WWW-Authenticate: Bearer+ JSON{"error": "invalid token"}on missing/wrong token. -
Step 1: Write failing tests
Create apps/device-host-agent/tests/test_mcp_auth.py:
from __future__ import annotations
from pathlib import Path
from starlette.applications import Starlette
from starlette.responses import JSONResponse, Response
from starlette.testclient import TestClient
from host_agent.mcp_token import McpTokenStore
from host_agent.web.mcp_auth import BearerAuthMiddleware
def _make_client(tmp_path: Path) -> tuple[TestClient, str]:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create().token
async def hello(request): # type: ignore[no-untyped-def]
return JSONResponse({"ok": True})
inner = Starlette(routes=[])
inner.router.add_route("/", hello, methods=["GET"])
wrapped = Starlette()
wrapped.router.add_middleware(BearerAuthMiddleware, token_store=store)
wrapped.mount("/", inner)
return TestClient(wrapped), token
def test_no_header_returns_401(tmp_path: Path) -> None:
client, _ = _make_client(tmp_path)
resp = client.get("/")
assert resp.status_code == 401
assert resp.headers["WWW-Authenticate"] == "Bearer"
assert resp.json() == {"error": "invalid token"}
def test_wrong_token_returns_401(tmp_path: Path) -> None:
client, _ = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": "Bearer wrong"})
assert resp.status_code == 401
def test_correct_token_passes_through(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_non_bearer_scheme_returns_401(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": f"Basic {token}"})
assert resp.status_code == 401
def test_header_case_insensitive(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"authorization": f"Bearer {token}"})
assert resp.status_code == 200
- Step 2: Run tests to verify they fail
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_mcp_auth.py -v
Expected: FAIL — host_agent.web.mcp_auth doesn't exist.
- Step 3: Implement
BearerAuthMiddleware
Create apps/device-host-agent/host_agent/web/mcp_auth.py:
"""Bearer-token auth middleware for the MCP sub-app.
Mounted on the FastMCP ``streamable_http_app()`` (NOT the console FastAPI),
so cookie-session auth on console routes is unaffected.
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from host_agent.mcp_token import McpTokenStore
class BearerAuthMiddleware(BaseHTTPMiddleware):
def __init__(self, app, token_store: McpTokenStore) -> None:
super().__init__(app)
self._store = token_store
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[no-untyped-def]
header = request.headers.get("Authorization")
if not header or not header.lower().startswith("bearer "):
return _unauthorized()
presented = header.split(" ", 1)[1].strip()
if not self._store.verify(presented):
return _unauthorized()
return await call_next(request)
def _unauthorized() -> JSONResponse:
return JSONResponse(
status_code=401,
content={"error": "invalid token"},
headers={"WWW-Authenticate": "Bearer"},
)
- Step 4: Run tests to verify they pass
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_mcp_auth.py -v
Expected: all 5 tests PASS.
- Step 5: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/web/mcp_auth.py apps/device-host-agent/tests/test_mcp_auth.py
Expected: clean.
- Step 6: Commit
git add apps/device-host-agent/host_agent/web/mcp_auth.py apps/device-host-agent/tests/test_mcp_auth.py
git commit -m "feat(host-agent): add BearerAuthMiddleware for MCP server"
Task 6: build_mcp_server — wrapped FastMCP server
The heart of the integration. Wraps each of the 11 tools from api.mcp.tool_handlers(manager=...) with:
- session_id extraction from FastMCP context
- busy check (cloud via
AgentStatusTracker, MCP viaMcpBusyTracker) - lazy acquire on first call against a device
- renew on every call
- status mapping for
list_devices/device_status(display status)
Files:
- Create:
apps/device-host-agent/host_agent/web/mcp.py - Test:
apps/device-host-agent/tests/test_web_mcp.py
Interfaces:
- Produces:
build_mcp_server(*, manager: DeviceManager, mcp_busy_tracker: McpBusyTracker, status_tracker: AgentStatusTracker) -> FastMCP - Produces:
McpDeviceBusyError(Exception)with.device_id,.busy_ownerattributes for callers that want structured info. - Produces: helpers
_cloud_busy_device_id(status_tracker: AgentStatusTracker) -> str | Noneand_display_status(device, busy_device_id) -> str(extracted fromhost_agent/web/app.py:64for reuse; do NOT remove the original fromapp.pyyet — Task 11 will consolidate).
Session ID extraction note: The mcp SDK 1.28.1 exposes session_id via mcp.server.fastmcp.Context. The exact attribute path should be verified by inspecting the installed SDK at runtime; a fallback contextvars-based middleware can be added if Context.session_id isn't reliable.
- Step 1: Verify session_id extraction from mcp SDK
Run: uv run --package device-host-agent python -c " import inspect from mcp.server.fastmcp import Context print([a for a in dir(Context) if 'session' in a.lower() or 'id' in a.lower()]) "
Expected output: shows attributes including something like session_id, request_id, or similar. Record the attribute name for use in Step 3.
If session_id (or equivalent) is NOT accessible via Context, fall back to a request-scoped contextvars.ContextVar[str] populated by a Starlette middleware on the mounted MCP app that reads request.scope["mcp_session_id"] (the mcp SDK sets this in ASGI scope).
- Step 2: Write failing tests for
build_mcp_server
Create apps/device-host-agent/tests/test_web_mcp.py:
from __future__ import annotations
from typing import Any
import pytest
from device.manager import DeviceManager
from driver.base import Driver
from host_agent.mcp_lock import McpBusyTracker
from host_agent.status import AgentStatusTracker
from host_agent.web.mcp import (
McpDeviceBusyError,
build_mcp_server,
_call_tool_sync,
)
class _FakeDriver(Driver):
"""Minimal driver: connect/screenshot/tap are the only methods exercised."""
def __init__(self) -> None:
self.taps: list[tuple[float, float]] = []
def connect(self) -> None:
return None
def disconnect(self) -> None:
return None
def screenshot(self) -> bytes:
return b"fake"
def tap(self, x: float, y: float) -> None:
self.taps.append((x, y))
# All other Driver methods left as no-ops/defaults — add stubs as needed
# for type-checking. (long_press, swipe, swipe_path, double_tap, input,
# launch, tree, home, get_active_app_metadata, press_keycode, etc.)
# Use `def __getattr__(self, name): return lambda *a, **k: None` if
# needed during early development, then replace with explicit stubs.
def _make_manager_with_device(device_id: str = "phone-1") -> DeviceManager:
manager = DeviceManager()
manager.register(
device_id=device_id,
driver_type="wda",
factory=lambda: _FakeDriver(),
name=device_id,
)
manager.connect(device_id)
return manager
def test_build_mcp_server_returns_fastmcp_instance() -> None:
from mcp.server.fastmcp import FastMCP
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
assert isinstance(server, FastMCP)
def test_call_tool_succeeds_when_device_is_free() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
assert result["ok"] is True
assert "phone-1" in tracker.busy_device_ids()
def test_call_tool_fails_when_cloud_uses_device() -> None:
"""AgentStatusTracker.current_assignment.device_id matches -> busy."""
from cloud.internal_api.models import AssignmentModel
from datetime import datetime, UTC
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
status.mark_assignment_started(
AssignmentModel(
task_id="t1",
attempt=1,
lease_id="l1",
lease_expires_at=datetime.now(UTC),
host_id="h1",
device_id="phone-1",
goal="cloud task",
)
)
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
with pytest.raises(McpDeviceBusyError) as exc:
_call_tool_sync(
server,
"take_screenshot",
{"device_id": "phone-1"},
session_id="sess-a",
)
assert exc.value.device_id == "phone-1"
assert exc.value.busy_owner == "cloud_assignment"
def test_call_tool_fails_when_another_mcp_session_holds_device() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
# Pre-acquire as a different session.
tracker.acquire("phone-1", "sess-other")
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
with pytest.raises(McpDeviceBusyError) as exc:
_call_tool_sync(
server,
"take_screenshot",
{"device_id": "phone-1"},
session_id="sess-a",
)
assert exc.value.busy_owner.startswith("mcp_session:")
def test_call_tool_renews_when_same_session_already_holds() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
_call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
# Second call from the same session should succeed.
result = _call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
assert result["ok"] is True
def test_list_devices_uses_display_status() -> None:
"""Connected-but-idle devices report as 'connected', not 'busy'."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(server, "list_devices", {}, session_id="sess-a")
assert isinstance(result, list)
assert result[0]["status"] == "connected"
def test_unknown_device_returns_value_error() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
with pytest.raises(Exception) as exc: # api.errors wraps as semantic error
_call_tool_sync(
server,
"take_screenshot",
{"device_id": "does-not-exist"},
session_id="sess-a",
)
# The error should mention the device somehow.
assert "does-not-exist" in str(exc.value)
def test_manager_required_for_tool_handlers() -> None:
"""Reinforces D12 — build_mcp_server itself requires non-None manager."""
# build_mcp_server signature already requires manager as keyword-only.
# This is a documentation test.
import inspect
sig = inspect.signature(build_mcp_server)
assert sig.parameters["manager"].kind == inspect.Parameter.KEYWORD_ONLY
- Step 3: Implement
host_agent/web/mcp.py
Create apps/device-host-agent/host_agent/web/mcp.py:
"""FastMCP server builder for the host-agent MCP endpoint.
Wraps ``api.mcp.tool_handlers(manager=...)`` with:
- Cloud-busy and MCP-busy checks (per-device, fail-fast on conflict)
- Lazy session-level device lock acquire / renew
- Display-status mapping for list_devices / device_status so connected-but-
idle devices don't appear "busy" (which they do at the DeviceManager
layer because an Appium/WDA session is open).
The builder returns a ``FastMCP`` instance. The caller (``create_console_app``)
is responsible for wrapping it in ``BearerAuthMiddleware`` and mounting at
``/mcp``.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from device.manager import DeviceManager
from host_agent.mcp_lock import McpBusyTracker
from host_agent.status import AgentStatusTracker
from mcp.server.fastmcp import FastMCP
# Tool names that don't target a specific device — skip busy check.
_NON_DEVICE_TOOLS = frozenset({"list_devices", "device_status"})
# Tools that report status and should use the display-status mapping.
_STATUS_TOOLS = frozenset({"list_devices", "device_status"})
class McpDeviceBusyError(Exception):
"""Raised by the wrapper when the target device is held by cloud or
another MCP session."""
def __init__(self, device_id: str, busy_owner: str) -> None:
super().__init__(f"device {device_id} is busy (held by {busy_owner})")
self.device_id = device_id
self.busy_owner = busy_owner
def build_mcp_server(
*,
manager: DeviceManager,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> FastMCP:
"""Construct the FastMCP server wrapping ``tool_handlers``."""
# Imported lazily to keep package import graph flat.
from api.mcp import tool_handlers
handlers = tool_handlers(manager=manager)
server = FastMCP("apex-host-agent")
for tool_name, raw_handler in handlers.items():
wrapped = _wrap_tool(
tool_name,
raw_handler,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
def _make_tool(name: str, fn: Callable[..., Any]) -> None:
@server.tool(name=name)
def _tool(*args: Any, **kwargs: Any) -> Any: # noqa: ANN202
return fn(*args, **kwargs)
_make_tool(tool_name, wrapped)
return server
def _wrap_tool(
tool_name: str,
handler: Callable[..., Any],
*,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> Callable[..., Any]:
def wrapped(*args: Any, **kwargs: Any) -> Any:
session_id = _current_session_id()
device_id = kwargs.get("device_id")
if tool_name in _STATUS_TOOLS:
return _with_display_status(handler, status_tracker, *args, **kwargs)
if device_id is not None and tool_name not in _NON_DEVICE_TOOLS:
_check_and_acquire(device_id, session_id, mcp_busy_tracker, status_tracker)
return handler(*args, **kwargs)
return wrapped
def _check_and_acquire(
device_id: str,
session_id: str,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> None:
cloud_busy = _cloud_busy_device_id(status_tracker)
if cloud_busy == device_id:
raise McpDeviceBusyError(device_id, "cloud_assignment")
if device_id in mcp_busy_tracker.busy_device_ids():
existing = next(
(
lease
for lease in mcp_busy_tracker.snapshot()
if lease.device_id == device_id
),
None,
)
if existing is not None and existing.session_id != session_id:
prefix = existing.session_id[:8]
raise McpDeviceBusyError(device_id, f"mcp_session:{prefix}")
if not mcp_busy_tracker.acquire(device_id, session_id):
# Race: someone else got it between check and acquire.
raise McpDeviceBusyError(device_id, "another_session")
mcp_busy_tracker.renew(device_id, session_id)
def _cloud_busy_device_id(status_tracker: AgentStatusTracker) -> str | None:
snap = status_tracker.snapshot()
current = snap.get("current_assignment")
if not isinstance(current, dict):
return None
device_id = current.get("device_id")
return device_id if isinstance(device_id, str) else None
def _with_display_status(
handler: Callable[..., Any],
status_tracker: AgentStatusTracker,
*args: Any,
**kwargs: Any,
) -> Any:
busy_device_id = _cloud_busy_device_id(status_tracker)
result = handler(*args, **kwargs)
if isinstance(result, list):
for item in result:
if isinstance(item, dict) and "status" in item:
item["status"] = _display_status(item["status"], item.get("id"), busy_device_id)
return result
if isinstance(result, dict) and "status" in result:
result["status"] = _display_status(
result["status"], result.get("device_id"), busy_device_id
)
return result
def _display_status(raw: str, device_id: Any, busy_device_id: str | None) -> str:
"""Mirror host_agent.web.app._device_display_status semantics:
a device that's locally 'busy' because it's connected-but-idle reports
'connected', unless it's the device currently running a cloud assignment.
"""
if raw == "busy" and device_id != busy_device_id:
return "connected"
return raw
def _current_session_id() -> str:
"""Extract session_id from the current FastMCP tool-call context.
The mcp SDK exposes session_id via Context; for sync handlers invoked
outside a request lifecycle (e.g. tests), fall back to a contextvars
value set by the test harness.
"""
# Try the FastMCP context first.
try:
from mcp.server.fastmcp import get_context
ctx = get_context()
# SDK 1.28 exposes session_id via the underlying server transport.
session_id = getattr(ctx, "session_id", None)
if isinstance(session_id, str) and session_id:
return session_id
request_id = getattr(ctx, "request_id", None)
if isinstance(request_id, str) and request_id:
return request_id
except Exception:
pass
# Test fallback.
return _TEST_SESSION_ID.get("")
# contextvars fallback for tests; production code uses FastMCP context.
import contextvars
_TEST_SESSION_ID: contextvars.ContextVar[str] = contextvars.ContextVar(
"_TEST_SESSION_ID", default=""
)
def _call_tool_sync(
server: FastMCP,
tool_name: str,
arguments: dict[str, Any],
*,
session_id: str,
) -> Any:
"""Test helper: invoke a registered tool synchronously with a forced
session_id. Bypasses the HTTP/MCP transport layer to keep tests fast."""
token = _TEST_SESSION_ID.set(session_id)
try:
# Walk FastMCP's tool registry to find the underlying callable.
tool = server._tool_manager.get_tool(tool_name) # type: ignore[attr-defined]
if tool is None:
raise KeyError(f"tool {tool_name!r} not registered")
# FastMCP Tool wraps a coroutine; our wrappers are sync, so unwrap.
return tool.fn(**arguments)
finally:
_TEST_SESSION_ID.reset(token)
- Step 4: Run tests to verify they pass
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_web_mcp.py -v
Expected: 8 tests PASS.
If _tool_manager.get_tool(tool_name).fn access pattern doesn't match mcp 1.28.1 internals, replace with introspection:
# Fallback for different FastMCP internal layouts:
tools = getattr(server, "_tool_manager", None)
if tools is not None:
registry = getattr(tools, "_tools", None) or getattr(tools, "tools", None)
if isinstance(registry, dict):
tool = registry.get(tool_name)
- Step 5: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/web/mcp.py apps/device-host-agent/tests/test_web_mcp.py
Expected: clean (or only minor style notes — fix what's flagged).
- Step 6: Commit
git add apps/device-host-agent/host_agent/web/mcp.py apps/device-host-agent/tests/test_web_mcp.py
git commit -m "feat(host-agent): wrap tool_handlers with busy check + status mapping"
Task 7: Cloud HeartbeatRequest schema — add mcp_busy_device_ids
Files:
- Modify:
packages/cloud-platform/cloud/internal_api/models.py:37-42 - Test:
packages/cloud-platform/tests/test_internal_api_models.py(create or extend)
Interfaces:
-
Produces:
HeartbeatRequestnow hasmcp_busy_device_ids: list[str] = Field(default_factory=list). -
Backward compat: requests without the field still validate (default
[]). -
Step 1: Locate or create the test file
Run: ls packages/cloud-platform/tests/
If test_internal_api_models.py exists, extend it; otherwise create it.
- Step 2: Write failing tests
Create or extend packages/cloud-platform/tests/test_internal_api_models.py:
from __future__ import annotations
from cloud.internal_api.models import HeartbeatRequest
def test_heartbeat_request_defaults_mcp_busy_device_ids_to_empty() -> None:
req = HeartbeatRequest(host_id="h1")
assert req.mcp_busy_device_ids == []
def test_heartbeat_request_accepts_mcp_busy_device_ids() -> None:
req = HeartbeatRequest(host_id="h1", mcp_busy_device_ids=["phone-1"])
assert req.mcp_busy_device_ids == ["phone-1"]
def test_heartbeat_request_omitting_field_is_backward_compatible() -> None:
"""Old host-agents that don't send the field must still validate."""
raw = {"host_id": "h1", "devices": []}
req = HeartbeatRequest.model_validate(raw)
assert req.mcp_busy_device_ids == []
- Step 3: Run tests to verify they fail
Run: uv run --package device-cloud-platform pytest packages/cloud-platform/tests/test_internal_api_models.py -v
Expected: FAIL — mcp_busy_device_ids not yet on the model.
- Step 4: Add the field
In packages/cloud-platform/cloud/internal_api/models.py, modify the HeartbeatRequest class (around lines 37-42):
class HeartbeatRequest(BaseModel):
host_id: str = Field(min_length=1)
address: str | None = None
devices: list[DeviceSnapshotModel] = Field(default_factory=list)
policy_revision: int = Field(default=0, ge=0)
planner_transport: Literal["direct", "cloud"] = "direct"
mcp_busy_device_ids: list[str] = Field(default_factory=list)
- Step 5: Run tests to verify they pass
Run: uv run --package device-cloud-platform pytest packages/cloud-platform/tests/test_internal_api_models.py -v
Expected: all 3 PASS.
- Step 6: Lint check
Run: uv run --with ruff ruff check packages/cloud-platform/cloud/internal_api/models.py packages/cloud-platform/tests/test_internal_api_models.py
Expected: clean.
- Step 7: Commit
git add packages/cloud-platform/cloud/internal_api/models.py packages/cloud-platform/tests/test_internal_api_models.py
git commit -m "feat(cloud): accept mcp_busy_device_ids in heartbeat payload"
Task 8: Cloud scheduler skips MCP-busy devices
Files:
- Modify:
packages/cloud-platform/cloud/pool.py:40-49(PooledDevicegetsmcp_busy: bool) - Modify:
packages/cloud-platform/cloud/pool.py:59-89(sync_host_devicesacceptsmcp_busy_device_ids) - Modify:
packages/cloud-platform/cloud/pool.py:117-134(_to_pooledsets flag) - Modify:
packages/cloud-platform/cloud/scheduler.py:206-220(_matcheschecksdevice.mcp_busy) - Modify:
packages/cloud-platform/cloud/internal_api/api.py:182-188(heartbeat handler passes field) - Test:
packages/cloud-platform/tests/test_pool.py(extend),packages/cloud-platform/tests/test_scheduler.py(extend),packages/cloud-platform/tests/test_internal_api.py(extend if exists)
Interfaces:
-
Produces:
PooledDevice.mcp_busy: bool = False -
Produces:
DevicePool.sync_host_devices(..., mcp_busy_device_ids: list[str] | None = None) -
Produces: scheduler's
_matches()returns False ifdevice.mcp_busy is True. -
Step 1: Locate existing tests
Run: ls packages/cloud-platform/tests/ | grep -E "pool|scheduler|internal_api"
- Step 2: Write failing tests for pool
Append to packages/cloud-platform/tests/test_pool.py:
def test_sync_host_devices_marks_mcp_busy_devices() -> None:
"""When a host reports device-1 as MCP-busy, the pool PooledDevice for
device-1 has mcp_busy=True."""
# Use the existing test harness in this file for constructing a pool.
# (Adjust to match the file's existing fixture style — see other tests
# in the same file for the exact setup pattern.)
pool = _build_pool() # _build_pool is a helper already in test_pool.py
pool.sync_host_devices(
"host-1",
[_device("device-1", status="idle")],
mcp_busy_device_ids=["device-1"],
)
devices = pool.list_devices()
busy = [d for d in devices if d.device_id == "device-1"]
assert len(busy) == 1
assert busy[0].mcp_busy is True
def test_sync_host_devices_default_mcp_busy_is_false() -> None:
pool = _build_pool()
pool.sync_host_devices(
"host-1", [_device("device-1", status="idle")]
)
devices = pool.list_devices()
assert devices[0].mcp_busy is False
def test_sync_host_devices_clears_mcp_busy_on_next_sync() -> None:
"""MCP releases device → next heartbeat without device in
mcp_busy_device_ids → pool reflects mcp_busy=False."""
pool = _build_pool()
pool.sync_host_devices(
"host-1",
[_device("device-1", status="idle")],
mcp_busy_device_ids=["device-1"],
)
pool.sync_host_devices(
"host-1", [_device("device-1", status="idle")]
)
devices = pool.list_devices()
assert devices[0].mcp_busy is False
If _build_pool / _device helpers don't exist, look at other tests in test_pool.py for the actual fixture names and reuse them.
- Step 3: Implement
PooledDevice.mcp_busyand pool plumbing
In packages/cloud-platform/cloud/pool.py:
- Add field to
PooledDevice(after line 49):
@dataclass(frozen=True)
class PooledDevice:
device_id: str
host_id: str
driver_type: str
status: PooledDeviceStatus
capability_tags: list[str] = field(default_factory=list)
synced_at: datetime | None = None
mcp_busy: bool = False
- Update
sync_host_devicessignature (around line 59-67) — addmcp_busy_device_idsparameter:
def sync_host_devices(
self,
host_id: str,
snapshot: list[Device],
*,
address: str | None = None,
planner_transport: Literal["direct", "cloud"] = "direct",
allow_device_takeover: bool = False,
mcp_busy_device_ids: list[str] | None = None,
) -> None:
now = utc_now()
self.store.upsert_host(
host_id,
address=address,
last_seen_at=now,
planner_transport=planner_transport,
)
busy_set = set(mcp_busy_device_ids or [])
devices = [
self._to_pooled(device, host_id, now, mcp_busy=device.id in busy_set)
for device in snapshot
]
# ... rest unchanged
- Update
_to_pooled(around line 117-134) — accept and pass throughmcp_busy:
def _to_pooled(
self,
device: Device,
host_id: str,
synced_at: datetime,
*,
mcp_busy: bool = False,
) -> PooledDevice:
raw_status = (
device.status if device.status in _HOST_REPORTED_STATUSES else "idle"
)
tags = list(device.capability_tags or [])
return PooledDevice(
device_id=device.id,
host_id=host_id,
driver_type=device.driver_type,
status=raw_status, # type: ignore[arg-type]
capability_tags=tags,
synced_at=synced_at,
mcp_busy=mcp_busy,
)
- In
packages/cloud-platform/cloud/internal_api/api.pyheartbeat handler (around line 182-188), pass the new field:
pool.sync_host_devices(
host_id,
devices,
address=payload.address,
allow_device_takeover=allow_device_takeover,
planner_transport=payload.planner_transport,
mcp_busy_device_ids=payload.mcp_busy_device_ids,
)
- Step 4: Run pool tests
Run: uv run --package device-cloud-platform pytest packages/cloud-platform/tests/test_pool.py -v
Expected: all PASS (new + existing).
- Step 5: Write failing scheduler test
Append to packages/cloud-platform/tests/test_scheduler.py:
def test_mcp_busy_device_is_skipped_by_scheduler() -> None:
"""A device with mcp_busy=True is not selected for assignment."""
# Use existing test fixtures; create a pool with one mcp-busy device and
# one idle device, submit a task, verify the idle one is selected.
pool = _build_pool_with_devices(
idle_device="dev-idle",
mcp_busy_device="dev-busy",
)
scheduler = _build_scheduler(pool)
task_id = scheduler.submit(goal="test", constraints=TaskConstraints())
assignments = scheduler.assign()
if assignments:
assert all(a.device_id != "dev-busy" for a in assignments)
Use the existing fixture pattern in test_scheduler.py (look at other tests to copy the setup style). If the existing fixtures don't fit, construct the scenario manually by calling pool.sync_host_devices(..., mcp_busy_device_ids=["dev-busy"]).
- Step 6: Implement
_matchescheck
In packages/cloud-platform/cloud/scheduler.py, modify _matches (lines 206-220). At the start of the function:
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
if device.mcp_busy:
return False
# ... rest unchanged
Also update scheduler.assign() (around line 172-178) to filter on mcp_busy. Actually, the existing device.status == "idle" filter doesn't catch mcp-busy devices (they're still status="idle" from the host). The cleanest fix is in _matches(), so do it there.
- Step 7: Run scheduler tests
Run: uv run --package device-cloud-platform pytest packages/cloud-platform/tests/test_scheduler.py -v
Expected: all PASS.
- Step 8: Run cloud-internal-api integration tests (if any)
Run: uv run --package device-cloud-platform pytest packages/cloud-platform/tests/ -v -k heartbeat
Expected: all PASS.
- Step 9: Lint check
Run: uv run --with ruff ruff check packages/cloud-platform/cloud/pool.py packages/cloud-platform/cloud/scheduler.py packages/cloud-platform/cloud/internal_api/api.py packages/cloud-platform/tests/test_pool.py packages/cloud-platform/tests/test_scheduler.py
Expected: clean.
- Step 10: Commit
git add packages/cloud-platform/cloud/pool.py packages/cloud-platform/cloud/scheduler.py packages/cloud-platform/cloud/internal_api/api.py packages/cloud-platform/tests/test_pool.py packages/cloud-platform/tests/test_scheduler.py
git commit -m "feat(cloud): skip MCP-busy devices in scheduler"
Task 9: Host-agent heartbeat sends mcp_busy_device_ids
Files:
- Modify:
apps/device-host-agent/host_agent/heartbeat.py:18-27(build_device_snapshotdoesn't return the busy ids; add a sibling helper or change the API) - Modify:
apps/device-host-agent/host_agent/heartbeat.py:30-53(HeartbeatSynchronizer.__init__acceptsmcp_busy_tracker) - Modify:
apps/device-host-agent/host_agent/heartbeat.py:58-85(sync_oncereads tracker and passes to client) - Modify:
apps/device-host-agent/host_agent/client.py:162+(heartbeat()accepts and sendsmcp_busy_device_ids) - Test:
apps/device-host-agent/tests/test_heartbeat.py(extend),apps/device-host-agent/tests/test_client.py(extend)
Interfaces:
-
Consumes:
McpBusyTracker.busy_device_ids()from Task 4. -
Produces:
HeartbeatSynchronizer.__init__(..., mcp_busy_tracker: McpBusyTracker | None = None). -
Produces:
HostAgentClient.heartbeat(snapshot, *, address=..., policy_revision=..., mcp_busy_device_ids: list[str] | None = None). -
Step 1: Read existing
HostAgentClient.heartbeatsignature
Run: uv run --package device-host-agent python -c "import inspect; from host_agent.client import HostAgentClient; print(inspect.signature(HostAgentClient.heartbeat))"
Record the current parameter list — you'll add mcp_busy_device_ids to the end of the kwargs.
- Step 2: Write failing test for client
Append to apps/device-host-agent/tests/test_client.py:
def test_heartbeat_includes_mcp_busy_device_ids_in_payload() -> None:
"""When mcp_busy_device_ids is passed, the client sends it in the request."""
# Use the existing mock-transport pattern from this file.
# See test_heartbeat_* or test_claim_* in the same file for the pattern.
client = _build_client_with_mock_transport()
captured = _capture_request_payload(client)
client.heartbeat(
[],
mcp_busy_device_ids=["phone-1"],
)
assert captured()["mcp_busy_device_ids"] == ["phone-1"]
def test_heartbeat_omits_mcp_busy_device_ids_when_empty() -> None:
"""Backward compat: empty list still goes over the wire (or is omitted,
depending on what the cloud model accepts — see Task 7 default_factory
accepts both). Verify whatever the implementation does."""
client = _build_client_with_mock_transport()
captured = _capture_request_payload(client)
client.heartbeat([], mcp_busy_device_ids=[])
# Either the key is present with [] or absent; both are valid per cloud model.
assert captured().get("mcp_busy_device_ids", []) == []
If _build_client_with_mock_transport / _capture_request_payload helpers don't exist, look at existing tests in test_client.py to copy the pattern.
- Step 3: Modify
HostAgentClient.heartbeat
Read apps/device-host-agent/host_agent/client.py around line 162 to find the heartbeat() method. Add mcp_busy_device_ids: list[str] | None = None parameter and include it in the POST body:
async def heartbeat(
self,
snapshot: list[DeviceSnapshotModel],
*,
address: str | None = None,
policy_revision: int = 0,
mcp_busy_device_ids: list[str] | None = None,
) -> HeartbeatResponse:
payload = {
"host_id": self._config.host_id,
"devices": [device.model_dump() for device in snapshot],
"policy_revision": policy_revision,
"planner_transport": self._config.ai_planner_transport,
}
if address is not None:
payload["address"] = address
if mcp_busy_device_ids:
payload["mcp_busy_device_ids"] = list(mcp_busy_device_ids)
# ... rest of method (POST + parse) unchanged
(The exact existing payload structure may differ slightly — match it.)
- Step 4: Run client tests
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_client.py -v
Expected: all PASS.
- Step 5: Write failing test for
HeartbeatSynchronizer
Append to apps/device-host-agent/tests/test_heartbeat.py:
async def test_sync_once_passes_mcp_busy_device_ids_to_client() -> None:
"""When mcp_busy_tracker has a lease, sync_once relays the device_ids."""
# Use existing test fixtures (mock manager, mock client) from this file.
manager = _build_manager_with_no_devices()
fake_client = _FakeClient()
tracker = McpBusyTracker()
tracker.acquire("phone-1", "sess-a")
sync = HeartbeatSynchronizer(
manager,
fake_client,
_config(),
mcp_busy_tracker=tracker,
)
await sync.sync_once()
assert fake_client.last_heartbeat_kwargs.get("mcp_busy_device_ids") == ["phone-1"]
async def test_sync_once_passes_empty_when_tracker_is_none() -> None:
"""Default: no tracker → no mcp_busy_device_ids kwarg (or empty)."""
manager = _build_manager_with_no_devices()
fake_client = _FakeClient()
sync = HeartbeatSynchronizer(manager, fake_client, _config())
await sync.sync_once()
# If the client method is called without the kwarg, that's fine — verify
# the call didn't pass a non-empty list.
assert not fake_client.last_heartbeat_kwargs.get("mcp_busy_device_ids")
Look at existing tests in test_heartbeat.py for the actual fixture/helper names.
- Step 6: Modify
HeartbeatSynchronizer
In apps/device-host-agent/host_agent/heartbeat.py:
-
Add
mcp_busy_tracker: McpBusyTracker | None = Noneto__init__(line 30-43) and store asself.mcp_busy_tracker. -
Import at top:
from host_agent.mcp_lock import McpBusyTrackerunderTYPE_CHECKING. -
In
sync_once(around line 58-85), computemcp_busy_device_idsand pass to client:
async def sync_once(self) -> HeartbeatResponse:
snapshot = build_device_snapshot(self.manager)
mcp_busy_ids = (
self.mcp_busy_tracker.busy_device_ids()
if self.mcp_busy_tracker is not None
else []
)
response = await self.client.heartbeat(
snapshot,
address=self.address,
policy_revision=self.policy_revision,
mcp_busy_device_ids=mcp_busy_ids,
)
# ... rest unchanged
- Step 7: Run heartbeat tests
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_heartbeat.py -v
Expected: all PASS.
- Step 8: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/heartbeat.py apps/device-host-agent/host_agent/client.py apps/device-host-agent/tests/test_heartbeat.py apps/device-host-agent/tests/test_client.py
Expected: clean.
- Step 9: Commit
git add apps/device-host-agent/host_agent/heartbeat.py apps/device-host-agent/host_agent/client.py apps/device-host-agent/tests/test_heartbeat.py apps/device-host-agent/tests/test_client.py
git commit -m "feat(host-agent): include mcp_busy_device_ids in heartbeat payload"
Task 10: AssignmentExecutor fail-fast on MCP-held device
Files:
- Modify:
apps/device-host-agent/host_agent/assignment.py:22-30(AssignmentExecutor.__init__accepts optionalmcp_busy_tracker) - Modify:
apps/device-host-agent/host_agent/assignment.py:31-57(executedoes the check at entry) - Test:
apps/device-host-agent/tests/test_assignment.py(extend)
Interfaces:
-
Produces:
AssignmentExecutor(factories, *, mcp_busy_tracker: McpBusyTracker | None = None). -
Behavior: if
mcp_busy_tracker is not None and assignment.device_id in tracker.busy_device_ids()at execute entry → returnAssignmentExecutionResult(status="failed", failure_reason="device held by active MCP session"). -
Step 1: Read current
AssignmentExecutor
Already read in earlier context — assignment.py:22-57. Confirm signature is still def __init__(self, factories: ExecutionFactories) before editing.
- Step 2: Write failing test
Append to apps/device-host-agent/tests/test_assignment.py:
def test_execute_fails_fast_when_mcp_session_holds_device() -> None:
"""Cloud assignment arriving for a device currently held by an MCP
session must fail immediately rather than fight for the device."""
from cloud.internal_api.models import AssignmentModel
from datetime import datetime, UTC
factories = _build_factories() # use existing fixture from this file
tracker = McpBusyTracker()
tracker.acquire("phone-1", "sess-mcp")
executor = AssignmentExecutor(factories, mcp_busy_tracker=tracker)
assignment = AssignmentModel(
task_id="t1",
attempt=1,
lease_id="l1",
lease_expires_at=datetime.now(UTC),
host_id="h1",
device_id="phone-1",
goal="some goal",
)
result = executor.execute(assignment)
assert result.status == "failed"
assert "MCP" in (result.failure_reason or "")
def test_execute_skips_check_when_tracker_is_none() -> None:
"""Default backward-compat: no tracker → no fail-fast."""
factories = _build_factories()
executor = AssignmentExecutor(factories) # no tracker
# Without a real workflow store / task runner this test gets harder;
# use a goal + a mock runner factory, or verify the entry-point path
# doesn't raise on the mcp_busy check. The minimum we need to prove is
# that None tracker doesn't crash — see other tests in this file for
# the existing pattern for asserting execute() runs through.
Look at existing test_assignment.py to copy _build_factories and any mock-task-runner patterns.
- Step 3: Modify
AssignmentExecutor
In apps/device-host-agent/host_agent/assignment.py:
class AssignmentExecutor:
def __init__(
self,
factories: ExecutionFactories,
*,
mcp_busy_tracker: McpBusyTracker | None = None,
) -> None:
self.factories = factories
self._progress = TaskProgressHolder()
self._mcp_busy_tracker = mcp_busy_tracker
# ... latest_progress() unchanged ...
def execute(
self,
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None = None,
stop_reason: Callable[[], str | None] = None,
) -> AssignmentExecutionResult:
self._progress.clear()
with bind_planner_execution_context(assignment):
if should_stop is not None and should_stop():
# existing logic
...
if self._mcp_busy_tracker is not None and (
assignment.device_id in self._mcp_busy_tracker.busy_device_ids()
):
return AssignmentExecutionResult(
status="failed",
failure_reason=(
f"device {assignment.device_id} is held by an active "
"MCP session"
),
)
# existing workflow / goal dispatch unchanged
...
Add from host_agent.mcp_lock import McpBusyTracker under TYPE_CHECKING.
- Step 4: Run tests
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_assignment.py -v
Expected: all PASS (new + existing).
- Step 5: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/assignment.py apps/device-host-agent/tests/test_assignment.py
Expected: clean.
- Step 6: Commit
git add apps/device-host-agent/host_agent/assignment.py apps/device-host-agent/tests/test_assignment.py
git commit -m "feat(host-agent): fail-fast cloud assignment when MCP holds device"
Task 11: Console mount wiring + /api/status field + dashboard row
Files:
- Modify:
apps/device-host-agent/host_agent/web/app.py:209-266(create_console_appsignature + body) - Modify:
apps/device-host-agent/host_agent/web/app.py:353-379(/api/statuspayload getsmcp_busy_devicesfield) - Modify:
apps/device-host-agent/host_agent/web/templates/dashboard.html(one MCP status row) - Test:
apps/device-host-agent/tests/test_web_app.py(extend)
Interfaces:
-
Produces:
create_console_app(..., mcp_server: FastMCP | None = None, mcp_token_store: McpTokenStore | None = None, mcp_busy_tracker: McpBusyTracker | None = None). When all three are provided, the app mounts/mcp. -
Produces:
GET /api/statusreturnsmcp_busy_devices: list[str]andmcp_endpoint: str | None(the latter is None when MCP not mounted). -
Produces: dashboard HTML includes
<tr><td>MCP</td><td>{...}</td></tr>status row. -
Step 1: Read current
/api/statushandler
Read apps/device-host-agent/host_agent/web/app.py around line 353 (@app.get("/api/status")). Record the response dict structure.
- Step 2: Write failing tests
Append to apps/device-host-agent/tests/test_web_app.py:
def test_console_app_mounts_mcp_when_all_components_provided(tmp_path) -> None:
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.web.mcp import build_mcp_server
from device.manager import DeviceManager
from host_agent.status import AgentStatusTracker
manager = DeviceManager()
status = 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
)
app = _build_console_app( # use existing helper from this file
manager=manager,
status_tracker=status,
mcp_server=server,
mcp_token_store=token_store,
mcp_busy_tracker=tracker,
)
client = TestClient(app)
# /mcp exists (not 404). Without auth it returns 401.
resp = client.post("/mcp/", headers={"Content-Type": "application/json"})
assert resp.status_code != 404
def test_console_app_does_not_mount_mcp_when_components_missing() -> None:
app = _build_console_app() # no mcp_* kwargs
client = TestClient(app)
resp = client.post("/mcp/", headers={"Content-Type": "application/json"})
assert resp.status_code == 404
def test_api_status_includes_mcp_busy_devices(tmp_path) -> None:
# Mount with a tracker that has one lease; verify /api/status shows it.
...
client = TestClient(app_with_mcp)
# Login first if the test helper requires session; see existing tests.
resp = client.get("/api/status")
assert resp.status_code == 200
body = resp.json()
assert "mcp_busy_devices" in body
assert "phone-1" in body["mcp_busy_devices"]
def test_dashboard_renders_mcp_status_row(tmp_path) -> None:
client = TestClient(app_with_mcp)
# login
resp = client.get("/")
assert resp.status_code == 200
assert b"MCP" in resp.content # the status row label
Use the existing helpers in test_web_app.py (look at other tests for the exact fixture style).
- Step 3: Modify
create_console_app
In apps/device-host-agent/host_agent/web/app.py:209-266:
- Add three new kwargs to signature:
mcp_server,mcp_token_store,mcp_busy_tracker(all default None). - After
app = FastAPI(...)and existing route setup, conditionally mount MCP:
if mcp_server is not None and mcp_token_store is not None:
from host_agent.web.mcp_auth import BearerAuthMiddleware
from starlette.middleware import Middleware
mcp_asgi = mcp_server.streamable_http_app()
# Wrap with bearer auth via a sub-Starlette app:
from starlette.applications import Starlette
authed = Starlette(
routes=[],
middleware=[Middleware(BearerAuthMiddleware, token_store=mcp_token_store)],
)
authed.router.mount("/", mcp_asgi) # bearer check wraps all mcp traffic
app.mount("/mcp", authed)
- In the
/api/statushandler (around line 353), add fields:
return {
# ... existing fields ...
"mcp_endpoint": "/mcp" if mcp_server is not None else None,
"mcp_busy_devices": (
mcp_busy_tracker.busy_device_ids() if mcp_busy_tracker is not None else []
),
}
- Step 4: Modify dashboard template
Edit apps/device-host-agent/host_agent/web/templates/dashboard.html. Find the existing status rows (heartbeat, host policy, etc.) and add after them:
<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>
Make sure the template context passes mcp_endpoint and mcp_busy_devices from the dashboard view handler. Look at the existing dashboard route in app.py (around line 320 @app.get("/", response_class=HTMLResponse)) to add these to the render context.
- Step 5: Run tests
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_web_app.py -v
Expected: all PASS.
- Step 6: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/web/app.py apps/device-host-agent/tests/test_web_app.py
Expected: clean.
- Step 7: Commit
git add apps/device-host-agent/host_agent/web/app.py apps/device-host-agent/host_agent/web/templates/dashboard.html apps/device-host-agent/tests/test_web_app.py
git commit -m "feat(host-agent): mount /mcp + surface MCP status in console"
Task 12: Wire all components in create_application
Files:
- Modify:
apps/device-host-agent/host_agent/app.py:155-288(create_applicationbody)
Interfaces:
-
Consumes: Tasks 3, 4, 5, 6, 9, 10, 11 (all the new components + plumbing).
-
Produces:
create_application()now constructsMcpTokenStore,McpBusyTracker,build_mcp_server, passes them tocreate_console_app,HeartbeatSynchronizer,AssignmentExecutor. -
Step 1: Read current
create_application
Read apps/device-host-agent/host_agent/app.py:155-288 again. Identify the insertion points:
-
After
resolved_manageris constructed (around line 182-186) — needed forbuild_mcp_server -
After
TaskMetadataStore/Timeline/AssignmentExecutorconstruction (around line 200-211) —AssignmentExecutorneedsmcp_busy_tracker -
Before
create_console_appcall (around line 212-228) — passes new kwargs -
Before
HeartbeatSynchronizerconstruction (around line 238-252) — passesmcp_busy_tracker -
Step 2: Add an integration test
Append to apps/device-host-agent/tests/test_app.py:
def test_create_application_wires_mcp_components(tmp_path, monkeypatch) -> None:
"""create_application produces an app whose console is mounted at /mcp
and whose heartbeat reads from the in-process McpBusyTracker."""
# Use the existing pattern in test_app.py for monkeypatching config to
# point at tmp_path. Look at other tests in the file for the exact setup.
app = create_application(config=_test_config(tmp_path))
# /mcp is reachable (401 without auth, not 404).
from starlette.testclient import TestClient
with TestClient(app.console_app) as client:
resp = client.post("/mcp/")
assert resp.status_code == 401 # auth required, not 404
# Token file exists.
assert (tmp_path / "host_mcp_token.json").exists()
Note: if HostAgentApplication doesn't expose console_app as an attribute, you'll need to add it in step 3.
- Step 3: Modify
create_application
In apps/device-host-agent/host_agent/app.py:
- After
resolved_manager = ...is set (around line 182), add construction of new components:
mcp_token_store = McpTokenStore(
resolved_config.identity_path.parent / "host_mcp_token.json"
)
mcp_token = mcp_token_store.load_or_create() # eager; logs on first creation
if mcp_token_store._path.exists() and not mcp_token_store._path.stat().st_size:
import logging
logging.getLogger(__name__).info(
"MCP token generated at %s", mcp_token_store._path
)
# Actually log on first creation only — adjust by storing previous existence.
A cleaner approach for the "log on first creation" requirement:
token_path = resolved_config.identity_path.parent / "host_mcp_token.json"
token_existed_before = token_path.exists()
mcp_token_store = McpTokenStore(token_path)
mcp_token = mcp_token_store.load_or_create()
if not token_existed_before:
import logging
logging.getLogger(__name__).info(
"MCP token generated at %s", token_path
)
- After
executor = AssignmentExecutor(...)construction (around line 204-211), passmcp_busy_tracker:
mcp_busy_tracker = McpBusyTracker(ttl_seconds=60.0)
executor = AssignmentExecutor(
create_execution_factories(...),
mcp_busy_tracker=mcp_busy_tracker,
)
- Build the MCP server before
create_console_app:
mcp_server = build_mcp_server(
manager=resolved_manager,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
- Pass new kwargs to
create_console_app(around line 212-228):
console_app = create_console_app(
config=resolved_config,
manager=resolved_manager,
config_store=config_store,
local_account_store=...,
identity_store=...,
history_store=history_store,
status_tracker=status_tracker,
session_manager=...,
enrollment_client=...,
host_client=client,
metadata_store=metadata_store,
timeline=timeline,
executor=executor,
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
)
- Pass
mcp_busy_trackertoHeartbeatSynchronizer(around line 238-252):
heartbeat = HeartbeatSynchronizer(
resolved_manager,
client,
resolved_config,
status_tracker=status_tracker,
mcp_busy_tracker=mcp_busy_tracker,
on_sync=...,
policy_cache=...,
on_policy_sync=...,
)
- Add
console_appto the returnedHostAgentApplicationdataclass if not already there (it currently only storesconsole_server). The test in Step 2 needs access to the FastAPI app for TestClient. Look at how the existing embedded uvicorn server is built (around line 229-236) and either:- Add
console_app: FastAPI | None = NonetoHostAgentApplicationdataclass, OR - Have the test build its own app via
create_console_appdirectly.
- Add
Option B (test builds its own app) is less invasive. Adjust the test in Step 2 accordingly.
- Step 4: Run app tests
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_app.py -v
Expected: all PASS (new + existing 9 create_application() calls).
- Step 5: Run all host-agent tests
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/ -v
Expected: all PASS.
- Step 6: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/app.py apps/device-host-agent/tests/test_app.py
Expected: clean.
- Step 7: Commit
git add apps/device-host-agent/host_agent/app.py apps/device-host-agent/tests/test_app.py
git commit -m "feat(host-agent): wire MCP server into create_application"
Task 13: CLI mcp-token subcommand
Files:
- Modify:
apps/device-host-agent/host_agent/cli.py:19-39 - Test:
apps/device-host-agent/tests/test_cli.py(extend)
Interfaces:
-
Produces:
device-host-agent mcp-tokenprints the current MCP token to stdout (generating if missing), then exits 0. -
Step 1: Write failing test
Append to apps/device-host-agent/tests/test_cli.py:
def test_mcp_token_subcommand_prints_token(tmp_path, capsys, monkeypatch) -> None:
monkeypatch.setenv("HOST_AGENT_IDENTITY_PATH", str(tmp_path / "host_identity.json"))
monkeypatch.setenv("HOST_AGENT_LOCAL_ACCOUNT_PATH", str(tmp_path / "host_local_account.json"))
# Possibly need to set other env vars per existing test pattern.
from host_agent.cli import main
main(["mcp-token"])
out = capsys.readouterr().out.strip()
assert len(out) >= 40 # token is ~43 chars
# Subsequent invocation prints the same token (idempotent).
main(["mcp-token"])
out2 = capsys.readouterr().out.strip()
assert out == out2
Look at existing test_cli.py for env setup patterns.
- Step 2: Implement subcommand
In apps/device-host-agent/host_agent/cli.py, extend the argparse subparsers and dispatch:
def main(argv: Sequence[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("setup", help="Create the local operator account")
subparsers.add_parser(
"mcp-token",
help="Print the MCP server bearer token (generating if missing)",
)
args = parser.parse_args(argv)
if args.command == "mcp-token":
_print_mcp_token()
return
try:
if args.command == "setup":
_run_setup()
return
config = _resolve_config_with_local_account()
except LocalAccountSetupError as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
try:
create_application(config=config).run()
except InstanceAlreadyRunningError as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
def _print_mcp_token() -> None:
config = load_host_agent_config()
store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
print(store.load_or_create().token)
Add imports: from host_agent.mcp_token import McpTokenStore.
- Step 3: Run tests
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_cli.py -v
Expected: all PASS.
- Step 4: Lint check
Run: uv run --with ruff ruff check apps/device-host-agent/host_agent/cli.py apps/device-host-agent/tests/test_cli.py
Expected: clean.
- Step 5: Commit
git add apps/device-host-agent/host_agent/cli.py apps/device-host-agent/tests/test_cli.py
git commit -m "feat(host-agent): add mcp-token CLI subcommand"
Task 14: Documentation
Files:
- Create:
docs/MCP_INTEGRATION.md - Modify:
docs/MACOS_IPHONE_SETUP.md(add a new section near the bottom)
No TDD for docs. Content is concrete and verifiable.
- Step 1: Create
docs/MCP_INTEGRATION.md
# Host-Agent MCP Server Integration
The host-agent process exposes a Streamable HTTP MCP server on the same
port as the local console (default `127.0.0.1:8765`), at path `/mcp`. This
lets any MCP-compatible client — Hermes Agent, Claude Desktop, custom
scripts using the `mcp` Python SDK — drive devices directly through the
same `DeviceManager` the cloud worker uses.
## Prerequisites
- Host-agent built from this repo (see `docs/MACOS_IPHONE_SETUP.md`).
- An MCP client that supports the Streamable HTTP transport (mcp SDK
1.20+ on the client side).
## Get the bearer token
The first time host-agent starts after this feature ships, it generates
a random bearer token and writes it to:
<identity_path.parent>/host_mcp_token.json
(Default: `tasks/host_mcp_token.json` next to `host_identity.json`.)
To print it for copy/paste:
device-host-agent mcp-token
To rotate: delete the file and restart host-agent. Old tokens stop
working immediately.
## Hermes Agent configuration
Add to `~/.hermes/config.yaml`:
```yaml
mcp_servers:
apex_device:
url: "http://127.0.0.1:8765/mcp"
headers:
Authorization: "Bearer <paste-token-here>"
Start (or restart) Hermes. Verify by asking Hermes to list devices:
Use the apex_device MCP to list connected devices.
Tools exposed
All 11 device tools from api/mcp.py:
take_screenshot(device_id?)tap(x, y, device_id?)swipe(start_x, start_y, end_x, end_y, duration_ms?, device_id?)input_text(text, device_id?)launch_app(app_id, device_id?)find_text(query, device_id?)find_icon(name, device_id?)get_ui_tree(device_id?, include_app_info?)describe_screen(device_id?)list_devices()device_status(device_id)
Concurrency model
- The cloud worker and MCP clients share the same
DeviceManager. - Per-device, session-level locking: the first caller (cloud or MCP) to touch a device holds it; the other side sees a busy error.
- MCP sessions hold their lock until the session ends OR 60 seconds of inactivity. Cloud assignments hold theirs until the assignment terminates.
- The cloud scheduler is told about MCP-held devices via the heartbeat
mcp_busy_device_idsfield, so it normally won't even try to dispatch to them. A 30-second window exists between an MCP acquire and the next heartbeat; during that window cloud may dispatch, and the host-agent will fail-fast the assignment withfailure_reason="device held by an active MCP session".
Network binding
The MCP endpoint is bound to the same address as the local console. By
default this is 127.0.0.1 (loopback only). To expose on a different
interface, set HOST_AGENT_CONSOLE_BIND_HOST AND
HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=true — both are required. This
is the same escape hatch the local console uses; there is no MCP-only
override.
Error responses
| Condition | HTTP / JSON-RPC | Body |
|---|---|---|
| Missing/wrong bearer token | HTTP 401 | {"error": "invalid token"} + WWW-Authenticate: Bearer |
| Device busy (cloud) | JSON-RPC -32000 |
"device X is busy (held by cloud assignment)", data.busy_owner = "cloud_assignment" |
| Device busy (other MCP) | JSON-RPC -32000 |
"...", data.busy_owner = "mcp_session:<prefix>" |
| Unknown device | JSON-RPC -32602 |
"unknown device: X" |
| Tool error | JSON-RPC -32000 |
Original exception message |
Troubleshooting
list_devicesreturns[]: no devices registered. Use the local console athttp://127.0.0.1:8765/to add one (Login → Devices).device X is busyeven when cloud console says device is idle: check whether another MCP session is holding it. The local console dashboard shows active MCP sessions and held device_ids.- Token verification fails after restart: confirm you copied the
token from the current
host_mcp_token.json, not an older one. Rotation = delete file + restart.
Out of scope (current version)
wait_until_usableMCP tool: implemented internally but not exposed. MVP callers must handle busy errors themselves.- MCP call history in the local console: only current state is surfaced, not a call log.
- Token rotation CLI: use delete-and-restart for now.
- Non-loopback binding without explicit opt-in.
- [ ] **Step 2: Add a section to `docs/MACOS_IPHONE_SETUP.md`**
Read the existing file structure first. Add a new section near the bottom (before any "Troubleshooting" appendix):
```markdown
## MCP server (Hermes Agent integration)
Host-agent now exposes an MCP server on the same port as the local
console (`127.0.0.1:8765/mcp`). To drive your iPhone from Hermes Agent
or any MCP-compatible client:
1. Start host-agent normally.
2. Get the bearer token: `device-host-agent mcp-token`.
3. Configure Hermes per `docs/MCP_INTEGRATION.md`.
The MCP path reuses the same WDA session that the cloud worker uses.
Per-device locking prevents both sides from driving the same device at
once; see `docs/MCP_INTEGRATION.md` for the full concurrency model.
- Step 3: Commit
git add docs/MCP_INTEGRATION.md docs/MACOS_IPHONE_SETUP.md
git commit -m "docs: add MCP integration guide"
Task 15: Final validation
Files: none (verification only)
- Step 1: Run full non-integration test suite
Run: uv run --all-packages pytest -m "not integration"
Expected: ALL PASS. Compare against the pre-change baseline (per project memory: ~687 passed / 54 deselected as of recent runs). Any new failures must be explained.
- Step 2: Run host-agent subpackage tests in isolation
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/
Expected: ALL PASS.
- Step 3: Run cloud-platform subpackage tests
Run: uv run --package device-cloud-platform pytest packages/cloud-platform/tests/
Expected: ALL PASS.
- Step 4: Ruff check + format check on all touched paths
Run: uv run --with ruff ruff check apps/device-host-agent/ api/mcp.py packages/cloud-platform/ tests/ docs/MCP_INTEGRATION.md docs/MACOS_IPHONE_SETUP.md
Expected: clean.
Run: uv run --with ruff ruff format --check apps/device-host-agent/ api/mcp.py packages/cloud-platform/ tests/
Expected: clean.
- Step 5: compileall on touched Python files
Run: uv run --all-packages python -m compileall apps/device-host-agent/host_agent/ api/mcp.py packages/cloud-platform/cloud/
Expected: no errors.
- Step 6: OpenSpec strict validation (regression only — we did NOT add a new proposal)
Run: openspec validate --strict
Expected: PASS (no openspec changes in this feature; validates existing specs still pass).
- Step 7: Architecture boundary regression
Run: uv run --package device-host-agent pytest apps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns -v
Expected: PASS — runtime/ and api/ packages still do not import host_agent or cloud.
- Step 8: Final manual sanity check
Run: `uv run --package device-host-agent python -c " from host_agent.app import create_application
Just exercise that import graph composes at module level.
print('imports ok')
"Expected:imports ok`.
- Step 9: Final commit (if anything was reformatted)
If ruff format fixed anything:
git add .
git commit -m "style: ruff format after MCP server integration"
Otherwise, no commit needed — the feature is fully committed across Tasks 1-14.
Open Items Flagged for User Decision
Per the brainstorming spec, this implementation does NOT go through the openspec proposal process. If you (the user) prefer to retroactively create an openspec change for traceability, do so as a follow-up — the spec at docs/superpowers/specs/2026-07-21-host-agent-mcp-server-design.md is structured to translate cleanly into an openspec proposal if desired.