Design for mounting a Streamable HTTP MCP server inside the host-agent process so Hermes Agent (or any MCP client) can drive devices directly. Reuses the existing console FastAPI + uvicorn on port 8765, adds bearer- token auth, per-device session-level locks with 60s TTL, and cloud coordination via a new heartbeat field. Cloud scheduler skips devices reported as MCP-busy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 KiB
24 KiB
Host-Agent MCP Server — Design Spec
- Date: 2026-07-21
- Status: Draft, pending user review
- Owner: Jerry Yan
- Target package:
apps/device-host-agent(主)+packages/cloud-platform(schema/scheduler 扩展)+api/mcp.py(一处签名收紧)
1. Overview
在 host-agent 进程内挂载 Streamable HTTP MCP server,让 Hermes Agent(或任意 MCP 客户端)作为外部大脑直接驱动 host-agent 管理的设备,与既有的 Cloud Control Plane 长轮询 worker 路径并存。
一句话:Hermes 当大脑,host-agent 当手;同一个 host-agent 进程同时服务两条职责,per-device 互斥,先到先得。
2. Background & Motivation
- 当前 host-agent 只能通过 Cloud Control Plane 派发任务驱动;操作员想直接用本地 Hermes Agent 临时操控设备时,必须先在 cloud 侧创建 task,路径长、延迟高、依赖网络。
api/mcp.py::create_mcp_server()已经用 FastMCP 暴露了 11 个设备工具(take_screenshot/tap/swipe/input_text/launch_app/find_text/find_icon/get_ui_tree/describe_screen/list_devices/device_status),但只服务于 Runtime 进程(port 8000),且没有可运行入口被 host-agent 复用。- Hermes Agent 官方支持 HTTP MCP client(见 Hermes MCP docs),配置长这样:
mcp_servers: apex_device: url: "http://127.0.0.1:8765/mcp" headers: Authorization: "Bearer <token>" - uv.lock 已锁定
mcp==1.28.1,支持 Streamable HTTP transport。 - 既有的
host-agent-local-console已立先例:host-agent 进程内 always-on 跑 FastAPI + uvicorn(默认 port 8765,loopback only),本次 MCP server 沿用同一 server、同一端口,仅新增一个/mcpmount。
3. Locked Decisions(来自 grill 阶段)
| # | 决策 | 备注 |
|---|---|---|
| D1 | Transport = Streamable HTTP | mcp SDK 1.28.1 支持 |
| D2 | Mount 路径 = /mcp,与 console 同端口(默认 8765) |
FastAPI app.mount() |
| D3 | 网络 = loopback only,沿用 HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK 双保险 |
不新增 bind 配置 |
| D4 | 鉴权 = 独立 MCP bearer token,存 host_mcp_token.json |
文件路径 = config.identity_path.parent / "host_mcp_token.json" |
| D5 | 并发锁 = per-device,先到先得 + fail-fast | 不做 wait 队列 |
| D6 | 设备状态映射 = 走 _device_display_status() 同款逻辑 |
避免"所有连上的设备看起来都 busy" |
| D7 | Cloud worker 与 MCP server 在同一 host-agent 进程并存 | 不互斥,共享 DeviceManager |
| D8 | Cloud ↔ MCP 协调 = 心跳上报 mcp_busy_device_ids,cloud scheduler 跳过 |
心跳 schema 扩展,cloud 侧 _matches() 一处改动 |
| D9 | MCP session 级 lazy acquire 锁,60s TTL 兜底 | session_id 来自 FastMCP 上下文 |
| D10 | Skill catalog 工具 MVP 不暴露,保留 create_mcp_server(skill_catalog_store=...) 参数化挂载点 |
未来可加 mutating 工具 |
| D11 | wait_until_usable 方法实现 + 单测,但调用方不接入 |
预留能力,MVP 全部 fail-fast |
| D12 | tool_handlers(manager) 改为必传 |
已 grep 确认无调用方依赖 None 默认,根治 DeviceNotFoundError 类静默回退地雷 |
4. Architecture
4.1 总览
┌──────────────────────────── host-agent 进程 ────────────────────────────┐
│ │
│ run_async() 主循环 │
│ ├─ HeartbeatSynchronizer ──reads──→ McpBusyTracker ──┐ │
│ │ (已有) payload.mcp_busy_device_ids │ │
│ ├─ AssignmentProcessor ──reads──→ AgentStatusTracker │ │
│ │ (已有,cloud 路径) .current_assignment │
│ │ └─ AssignmentExecutor.execute() 启动前 fail-fast │ │
│ │ 检查 mcp_busy_tracker.busy_device_ids() │ │
│ └─ Console uvicorn server(已有 port 8765) │ │
│ └─ FastAPI app │ │
│ ├─ /login, /devices, /tasks, ... (已有) │ │
│ └─ Mount("/mcp") │ │
│ └─ FastMCP.streamable_http_app() │ │
│ ├─ BearerAuthMiddleware ──verify──→ McpTokenStore │
│ └─ 11 tools(包装层) │ │
│ ├─ busy check ────────────────────┘ │
│ │ (cloud 占用? McpBusyTracker 占用?) │
│ ├─ acquire device lock(首次调用时) │
│ ├─ tool_handlers(manager=<host_agent_manager>)[name]│
│ └─ release on session end / TTL │
│ │
└─────────────────────────────────────────────────────────────────────────┘
▲ ▲
│ HTTP + Bearer │ HTTP heartbeat
│ │ + mcp_busy_device_ids
┌───────────┴───────────┐ ┌─────────┴─────────┐
│ Hermes Agent (client) │ │ Cloud Control │
│ ~/.hermes/config.yaml │ │ Plane (server) │
│ mcp_servers.apex.url │ │ scheduler skips │
│ = http://127.0.0.1 │ │ mcp-busy devices │
│ :8765/mcp │ │ │
└────────────────────────┘ └────────────────────┘
4.2 关键不变量
DeviceManager单例;cloud 路径和 MCP 路径都通过显式参数注入。包装层在manager is None时抛错而非回退DEFAULT_MANAGER(D12)。- 同一设备同一时刻只能被一边占用:cloud 占用 → MCP busy 错误;MCP 占用 → 心跳上报 → cloud scheduler 跳过;窗口期内冲突由 AssignmentExecutor 启动前 fail-fast 兜底。
runtime//api/包边界不破坏:所有新代码在host_agent/;api/mcp.py只做一处签名收紧。
5. Components
5.1 host_agent/mcp_lock.py(新)— McpBusyTracker
@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=None) -> None: ...
def acquire(self, device_id: str, session_id: str) -> bool: ...
def renew(self, device_id: str, session_id: str) -> bool: ...
def release(self, session_id: str) -> list[str]: ...
def release_device(self, device_id: str, session_id: str) -> bool: ...
def busy_device_ids(self) -> list[str]: ... # lazy sweep
def snapshot(self) -> list[McpDeviceLease]: ... # lazy sweep, for UI
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: ... # 预留,MVP 不被调用
- 进程内单实例,
threading.Lock保护。 - TTL sweep 在
busy_device_ids()/snapshot()调用时 lazy 执行。 - Cloud 占用检查不放这里——保持单一职责;调用方在 acquire 前查
AgentStatusTracker。
5.2 host_agent/mcp_token.py(新)— McpTokenStore
@dataclass(frozen=True)
class McpToken:
version: int # =1
token: str # secrets.token_urlsafe(32)
created_at: datetime
class McpTokenStore:
def __init__(self, path: Path, *, now=None) -> None: ...
def load_or_create(self) -> McpToken: ... # atomic, 0o600
def verify(self, presented: str) -> bool: ...
- 文件路径:
config.identity_path.parent / "host_mcp_token.json"。 - JSON 格式:
{"version": 1, "token": "...", "created_at": "2026-07-21T..."}。 - 写文件用
tempfile + os.replace原子重命名;权限 0o600。 - 文件损坏或不可写 →
McpTokenStoreError,不静默重新生成。
5.3 host_agent/web/mcp_auth.py(新)— Bearer Auth Middleware
class BearerAuthMiddleware(BaseHTTPMiddleware):
def __init__(self, app, token_store: McpTokenStore) -> None: ...
# 401 + WWW-Authenticate: Bearer + JSON {"error":"invalid token"} on fail
- 挂在 FastMCP
streamable_http_app()外层(不是 console FastAPI 外层)。 - 不写失败日志(避免暴力枚举刷屏);首次成功鉴权写 INFO。
5.4 host_agent/web/mcp.py(新)— build_mcp_server()
def build_mcp_server(
*,
manager: DeviceManager,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> FastMCP:
"""Wraps api.mcp.tool_handlers(manager=manager) with:
- busy check (cloud OR mcp-busy → JSON-RPC -32000)
- lazy device lock acquire on first call
- lease renew on every call
- status mapping for list_devices / device_status (display_status)"""
- 单一 decorator 包装所有 11 个工具,避免重复。
session_id来自 FastMCP streamable HTTP 上下文。list_devices/device_status不走 busy 检查(无 device_id 参数),但走 display status 映射。
5.5 改动的现有模块
| 模块 | 改动 |
|---|---|
host_agent/app.py::create_application() |
构造 McpTokenStore / McpBusyTracker / build_mcp_server(),传给 create_console_app();InstanceLock acquire 后、heartbeat 启动前完成 token 生成 |
host_agent/web/app.py::create_console_app() |
新参数 mcp_server / mcp_token_store / mcp_busy_tracker;app.mount("/mcp", auth_wrapped(server.streamable_http_app()));/api/status 加 mcp_busy_devices 字段 |
host_agent/web/templates/dashboard.html |
一行 MCP 状态显示 |
host_agent/cli.py |
新子命令 mcp-token 打印当前 token(不存在则生成) |
host_agent/heartbeat.py |
payload 加 mcp_busy_device_ids 字段,值来自 mcp_busy_tracker.busy_device_ids() |
host_agent/assignment.py::AssignmentExecutor |
构造参数加 mcp_busy_tracker: McpBusyTracker | None = None(None 时跳过检查,保持向后兼容;host-agent 装配时必传);execute() 启动前(_execute_goal / _execute_workflow 实际跑之前)fail-fast 检查 mcp_busy_tracker.busy_device_ids(),命中则返回 status="failed" + failure_reason="device held by active MCP session" |
cloud/internal_api/models.py |
Heartbeat payload schema 加 mcp_busy_device_ids: list[str] = [] |
cloud/scheduler.py::_matches() |
心跳里的 mcp_busy_device_ids 内的 device 视为非 idle |
api/mcp.py::tool_handlers |
签名改为 tool_handlers(*, manager: DeviceManager)(必传,D12 根治) |
6. Data Flows
6.1 启动
create_application()
├─ InstanceLock acquire(已有)
├─ resolve_host_identity()(已有)
├─ DeviceManager 构造 + 设备注册(已有)
├─ McpTokenStore(identity_path.parent / "host_mcp_token.json").load_or_create()
│ └─ 首次:生成 token、atomic 写文件、INFO 日志 "MCP token generated at <path>"
├─ McpBusyTracker(ttl_seconds=60)
├─ build_mcp_server(manager=manager, mcp_busy_tracker=..., status_tracker=...)
└─ create_console_app(..., mcp_server=server, mcp_token_store=..., mcp_busy_tracker=...)
└─ app.mount("/mcp", auth_wrapped(server.streamable_http_app()))
run_async() 主循环启动(行为不变):
├─ heartbeat_task: 每 30s 读 mcp_busy_tracker.busy_device_ids() → payload
├─ claim loop: 不变(仍会 claim cloud 任务)
└─ console_server: 现在同时服务 / * (HTML) 和 /mcp (Streamable HTTP)
6.2 Hermes 第一次 MCP 调用(lazy acquire)
1. Hermes → POST /mcp (JSON-RPC tools/call, name=take_screenshot, args={device_id: "phone-1"})
2. BearerAuthMiddleware: verify token → pass
3. FastMCP route → 包装层 decorator:
a. session_id = ctx.session_id
b. busy check:
- cloud 占用? → status_tracker.snapshot()["current_assignment"]["device_id"] == "phone-1"? NO
- mcp 占用? → mcp_busy_tracker.busy_device_ids() 包含 "phone-1"? NO
c. acquire: mcp_busy_tracker.acquire("phone-1", session_id) → True
d. tool_handlers(manager=...)["take_screenshot"](device_id="phone-1") → screenshot
e. renew: mcp_busy_tracker.renew("phone-1", session_id)
f. return screenshot_base64
4. 下一次心跳(≤30s): payload.mcp_busy_device_ids = ["phone-1"]
5. Cloud scheduler 收到 → 把 phone-1 视为非 idle → 不派任务给它
6.3 同 session 第二次调用(锁已持有)
1. Hermes → POST /mcp (tap, device_id: "phone-1")
2-3a. 同上
3b. mcp_busy_tracker.busy_device_ids() 包含 "phone-1",但持有者就是当前 session_id → 通过
3c. acquire 已持有,no-op(或 assert)
3d-f. 同上
6.4 Cloud 任务抢占的预防
场景:Hermes 正在用 phone-1,cloud 这时派任务给 phone-1
正常路径:
1. Cloud scheduler 选设备:phone-1 在最近心跳的 mcp_busy_device_ids 内 → 视为非 idle → 跳过
2. Cloud 选 phone-2 或等其他设备 idle
窗口期兜底(心跳 30s 内 cloud 基于旧心跳派任务):
1. AssignmentProcessor.process(assignment) → AssignmentExecutor.execute(assignment)
2. execute() 启动前检查:
if assignment.device_id in mcp_busy_tracker.busy_device_ids():
return AssignmentExecutionResult(
status="failed",
failure_reason="device held by active MCP session"
)
3. client.report_result(...) 上报 fail → cloud attempt+1 或派给别的 host
6.5 Session 结束 / TTL 过期
正常:Hermes 主动断开 → FastMCP 触发 session shutdown callback
→ mcp_busy_tracker.release(session_id) → 该 session 持有的所有 lease 释放
→ 下次心跳 payload 不再包含这些 device_id → cloud 重新视为 idle
异常:Hermes 崩溃 / 网络断 → 无 shutdown callback
→ 60s TTL 到期 → 下次 busy_device_ids() 或 snapshot() 调用时 lazy sweep
→ lease 清理 → 下次心跳 payload 不再包含 → cloud 重新视为 idle
7. Error Handling Matrix
| # | 触发条件 | 返回语义 | 备注 |
|---|---|---|---|
| 1 | Authorization 缺失/不匹配 |
HTTP 401 + WWW-Authenticate: Bearer + JSON {"error":"invalid token"} |
不写失败日志;首次成功鉴权写 INFO |
| 2 | Cloud 占用目标设备 | JSON-RPC -32000 + "device X is busy (held by cloud assignment)" + data.busy_owner = "cloud_assignment" |
DEBUG 日志 |
| 3 | 另一 MCP session 占用 | JSON-RPC -32000 + "device X is busy (held by another MCP session)" + data.busy_owner = "mcp_session:<prefix>" |
DEBUG 日志 |
| 4 | device_id 不存在 |
JSON-RPC -32602 + "unknown device: X" |
复用 call_with_semantic_errors |
| 5 | 工具底层异常 | JSON-RPC -32000 + 原异常 message |
WARNING + exc_info |
| 6 | Cloud assignment 启动前命中 MCP 占用 | AssignmentExecutionResult(status="failed", failure_reason="device held by active MCP session") |
INFO 一次 |
| 7 | Token 文件损坏 JSON | host-agent 启动失败,stderr 提示 | 不静默重新生成 |
| 8 | Token 文件不可写 | host-agent 启动失败 | 同上 |
| 9 | MCP session 异常断开 | lease 进入 TTL 倒计时 | 60s 后 lazy sweep |
| 10 | TTL 过期瞬间 Hermes 重连 | renew 容忍边界:session_id 匹配 → 重新 acquire 而非报错 | 无感 |
| 11 | 同 session 并发不同设备 | 各自独立 acquire | per-device 设计 |
| 12 | 同 session 并发同一设备 | 第一个 acquire;第二个 renew(同 session_id) | 并发 safe |
| 13 | FastMCP 提取不到 session_id | JSON-RPC -32001 + "cannot determine MCP session" |
ERROR + exc_info |
| 14 | host-agent 关停时有 active MCP session | lease 随进程退出消失 | 不需显式清理 |
错误返回格式
{
"jsonrpc": "2.0",
"id": "<request-id>",
"error": {
"code": -32000,
"message": "device phone-1 is busy (held by cloud assignment)",
"data": {
"device_id": "phone-1",
"busy_owner": "cloud_assignment"
}
}
}
8. Testing Strategy
8.1 单元测试
McpBusyTracker(~10 用例):acquire/renew/release/busy_device_ids/snapshot/wait_until_usable 的成功/冲突/并发/TTL sweepMcpTokenStore(~6 用例):首次创建/二次复用/并发 atomic/verify timing-safe/损坏 JSON 抛错/不可写抛错BearerAuthMiddleware(~5 用例):无 header/错误 token/正确 token/非 Bearer scheme/大小写- 包装层 decorator(~10 用例,重点):
- 显式
manager=传入 → 工具调用成功(回归 D12 地雷) manager=None→ 包装层断言失败(新约束)- cloud 占用 →
-32000busy(mock AgentStatusTracker) - 另一 MCP session 占用 →
-32000busy - 同 session 已持有 → renew 后通过
- acquire/renew 调用顺序
- 底层
DeviceNotFoundError→-32602 - 底层
DriverError→-32000 list_devices不 busy check 但走 display statusdevice_status同上
- 显式
8.2 集成测试
- Mount wiring(~4 用例):
/mcpmount 存在、不继承 cookie session、/api/status加字段、dashboard HTML 含状态行 - 心跳 payload(~3 用例):空 / 含 phone-1 / TTL 过期后空
- AssignmentExecutor fail-fast(~2 用例):命中 MCP 占用立即 fail、不命中正常执行
- Cloud 侧(~4 用例):Heartbeat model 向后兼容空 list、含 device、
_matches()跳过 mcp_busy、旧 host-agent 不发字段时行为不变
8.3 端到端(1 个,可选)
- MCP 客户端模拟(
mcpSDK client + ASGI TestClient)→ host-agent 进程内 → FakeDriver - 覆盖:鉴权 → list_devices → screenshot → tap → 中途 cloud assignment fail-fast → session 断开 release
- 如果 SDK client + TestClient 组合有坑,降级为直接调用 FastMCP tool registry
8.4 已有测试不破坏
test_app.py9 处create_application()调用:新组件在create_application()内部构造,外部 API 不变test_runtime_owned_packages_do_not_import_host_or_cloud_concerns:所有新代码在host_agent/,不动runtime//api/包边界(api/mcp.py签名收紧不算破坏)- 根非集成测试套件:跑一遍无回归
8.5 人工/真机(不在自动化覆盖)
- 真实 Hermes Agent CLI 接入(写 SOUL.md、跑真实对话)
- 真机 iPhone/Android MCP 工具调用
9. Scope
9.1 In Scope
见第 5 节 Components 表。共:
- 4 个新模块(
mcp_lock.py/mcp_token.py/web/mcp_auth.py/web/mcp.py) - 8 个改动的现有模块
- 2 个文档(
docs/MCP_INTEGRATION.md新增 +docs/MACOS_IPHONE_SETUP.md加小节) - 单元 + 集成测试全覆盖;e2e 视成本而定
9.2 Non-Goals
wait_until_usable的调用方接入(方法实现 + 单测,但 AssignmentExecutor 和 MCP 工具包装层都不调用)- 暴露
wait_until_device_usable成独立 MCP 工具 device-host-agent mcp-rotate-tokenCLI(MVP 轮换走"删文件 + 重启")- MCP 调用进
ConsoleHistoryStore(MVP 不记录调用历史) - Skill catalog 工具暴露给 Hermes(保留参数化挂载点)
- Token rate limiting / 失败计数
- 多 Hermes 实例协调(技术允许但不专门测试)
- 远程访问(非 loopback)支持
- Hermes 侧 SOUL.md / 配置自动化
- MCP over WebSocket
- Cloud scheduler 改造为支持"MCP 优先级"/队列
10. Open Questions
- Q1(已答):
tool_handlers(manager)是否改为必传?→ 是。Grep 全仓库确认所有调用方(api/mcp.py:110、tests/test_mcp.py:13,56、tests/test_skill_catalog_e2e.py:287)都已显式传manager=manager,改动面为 0。 - Q2:
mcp-tokenCLI 是否需要鉴权?→ MVP 不鉴权,假定能访问宿主机的操作者可信(与setup子命令同款)。后续可加--password校验 local_account。 - Q3:MCP 工具调用是否需要 wall-clock 超时?→ MVP 不加,依赖底层超时;如有"Hermes 调用挂死"报告再加。
- Q4:心跳扩展是 Alembic migration 还是仅 schema 字段?→ MVP 仅 transient 字段(heartbeat 接收 → scheduler 用 → 丢弃),无需 migration。
- Q5(待实测):60s TTL 是否合适?→ 实测后调整。太短容易误释放正常 session 的锁;太长 Hermes 崩溃后设备不可用窗口大。
11. Risks
- R1:Cloud 心跳窗口期(30s)冲突。缓解:AssignmentExecutor 启动前 fail-fast。残留:cloud 可能基于过期心跳派任务、host-agent fail、cloud 重试——浪费 attempt 配额。接受。
- R2:Hermes 长时间占用设备导致 cloud 任务反复 fail。MVP 无自动缓解;用户手动管控;未来启用
wait_until_usable。接受。 - R3:Hermes 崩溃后 60s 内设备不可用。TTL 兜底但窗口存在。接受,记入 Q5。
- R4(已消除):
tool_handlers签名改动影响面。Grep 确认 0 调用方依赖 None 默认。 - R5:FastMCP
session_id提取依赖mcpSDK 内部 API。缓解:e2e 测试覆盖;SDK 升级 CI 能及时暴露。 - R6:
mcpSDK 需作为 device-host-agent 直接依赖(目前通过 Runtime 传递)。需加进apps/device-host-agent/pyproject.toml(与filelock直接化先例一致)。 - R7:Token 文件首次生成的竞态。缓解:InstanceLock 前置保护;
tempfile + os.replace原子重命名。
12. Coordination with Existing Changes
host-agent-single-instance-lock:本次依赖 InstanceLock,token 文件生成在 InstanceLock acquire 之后,安全。无需修改。host-agent-dependency-supervisor:本次不引入新的外部进程依赖(FastMCP 是库)。不冲突。task-execution-progress-visibility:本次不改TaskMetadataStore/Timeline。不冲突。host-agent-local-console:本次复用其 FastAPI + uvicorn 设施,新增一个 mount 点。Console 现有 cookie session 鉴权不继承到/mcp(鉴权走独立 bearer middleware)。
13. Hermes 侧配置示例(参考,非 host-agent 代码范围)
~/.hermes/config.yaml:
mcp_servers:
apex_device:
url: "http://127.0.0.1:8765/mcp"
headers:
Authorization: "Bearer <token-from-host_mcp_token.json>"
首次启动 host-agent 后,从 tasks/host_mcp_token.json(或 HOST_AGENT_IDENTITY_PATH 同目录)读 token,或跑 device-host-agent mcp-token 打印。
Hermes 的 SOUL.md(profile 级别)建议补充:
- 工具调用前先
list_devices看可用设备 - 设备
busy时等待或换设备 - iOS 设备坐标是 points(非 pixels),Android 是 pixels
- OCR/UI 树 bounds 与 screenshot 像素已对齐(perception 层已处理)
14. Implementation Order(建议,writing-plans 阶段细化)
api/mcp.py::tool_handlers签名收紧(D12)+ 同步更新调用方 docstring/类型McpTokenStore+ 单测McpBusyTracker(含wait_until_usable)+ 单测BearerAuthMiddleware+ 单测build_mcp_server包装层 + 单测(覆盖 D12 回归)create_console_appmount wiring + 集成测试create_application装配- 心跳 payload 扩展 + cloud scheduler 改动 + 集成测试
AssignmentExecutorfail-fast 检查 + 测试- CLI
mcp-token子命令 - Dashboard 状态行
- 文档(
MCP_INTEGRATION.md+MACOS_IPHONE_SETUP.md) - e2e 测试(可选)
- 全量非集成测试回归 + ruff + compileall + openspec strict validation