Files
agentic-mobile-control/docs/superpowers/specs/2026-07-21-host-agent-mcp-server-design.md
T
q792602257andClaude Opus 4.6 70e0624a47 fix(host-agent): align MCP integration with mcp SDK 1.28.1 realities
Three final-review deviations closed:

I1 (session-end release): mcp SDK 1.28.1 exposes no per-session
shutdown callback (only a server-level lifespan). Lower the
McpBusyTracker default TTL from 60s to 20s and update spec §6.5,
Q5/R3, D9, and docs/MCP_INTEGRATION.md concurrency section to
document the TTL-only recovery path. 20s is short enough to recover
within one 30s heartbeat interval but long enough that an active
session does not lose its lease during normal operator pauses.

I2 (JSON-RPC error shape): FastMCP Tool.run wraps every non-
UrlElicitationRequiredError exception (including McpError with typed
ErrorData) into ToolError, which the lowlevel call_tool handler
serializes as CallToolResult(isError=true, content=[TextContent(...)]).
There is no public path that surfaces JSON-RPC -32000 with structured
data.busy_owner from a tool call site. Update spec §7 error matrix
and docs/MCP_INTEGRATION.md error table to document the actual wire
shape; busy_owner now lives in the text content.

I3 (typing): mcp_server: Any = None -> FastMCP | None = None via
TYPE_CHECKING, keeping the mcp import lazy (matches precedent
elsewhere in the codebase) while adding static type checking at the
create_console_app boundary.

Tests added (4):
- test_default_ttl_is_20_seconds — locks I1's new default TTL
- test_default_ttl_recovers_dead_session_within_one_window — locks
  I1's recovery semantics (lease sweeped on next read after 20s)
- test_busy_error_wire_shape_is_calltoolresult_iserror — pins I2's
  wire envelope via Tool.run + lowlevel Server._make_error_result
- test_busy_error_text_includes_cloud_assignment_owner — same for
  the cloud_assignment busy_owner branch

Full non-integration suite: 697 passed / 54 deselected (was 693 / 54).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 16:25:35 +08:00

26 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、同一端口,仅新增一个 /mcp mount。

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 锁,20s TTL 兜底(mcp SDK 1.28.1 无 session-end callback,见 §6.5) 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 = 20.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_trackerapp.mount("/mcp", auth_wrapped(server.streamable_http_app()))/api/statusmcp_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 主动断开 → mcp SDK 1.28.1 没有 per-session shutdown hook
      → 该 session 的 lease 进入 TTL 倒计时
      → 20s TTL 到期 → 下次 busy_device_ids() 或 snapshot() 调用时 lazy sweep
      → lease 清理 → 下次心跳 payload 不再包含 → cloud 重新视为 idle

异常:Hermes 崩溃 / 网络断 → 同上,无 shutdown callback
      → 20s TTL 到期 → 下次 busy_device_ids() 或 snapshot() 调用时 lazy sweep
      → lease 清理 → 下次心跳 payload 不再包含 → cloud 重新视为 idle

Implementation note (2026-07-21 fix wave): mcp SDK 1.28.1 exposes only a server-level lifespan hook; ServerSession.__aexit__ and StreamableHTTPSessionManager do not surface a per-session shutdown callback. The spec originally described a release-on-clean- disconnect path that the SDK cannot deliver today. The fallback is the 20-second TTL sweep — short enough that a normal heartbeat interval (30s) catches the recovery before the cloud scheduler notices, long enough that an actively-busy session does not lose its lease during normal operator pauses. Explicit release on session end remains a future enhancement if/when the SDK exposes the hook.

7. Error Handling Matrix

# 触发条件 返回语义 备注
1 Authorization 缺失/不匹配 HTTP 401 + WWW-Authenticate: Bearer + JSON {"error":"invalid token"} 不写失败日志;首次成功鉴权写 INFO
2 Cloud 占用目标设备 CallToolResult(isError=true, content=[TextContent("device phone-1 is busy (held by cloud assignment)")]) DEBUG 日志
3 另一 MCP session 占用 CallToolResult(isError=true, content=[TextContent("device phone-1 is busy (held by mcp_session:<8-char-prefix>)")]) DEBUG 日志
4 device_id 不存在 CallToolResult(isError=false) + JSON {"ok": false, "error": "device not found: phone-1"} 复用 call_with_semantic_errors;语义错误不抛
5 工具底层异常 CallToolResult(isError=true, content=[TextContent("Error executing tool <name>: <orig-msg>")]) 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 倒计时 20s 后 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 CallToolResult(isError=true, content=[TextContent("cannot determine MCP session")]) ERROR + exc_info
14 host-agent 关停时有 active MCP session lease 随进程退出消失 不需显式清理

错误返回格式

{
  "jsonrpc": "2.0",
  "id": "<request-id>",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "device phone-1 is busy (held by cloud assignment)"
      }
    ],
    "isError": true
  }
}

Implementation note (2026-07-21 fix wave): mcp SDK 1.28.1's Tool.run wraps every non-UrlElicitationRequiredError exception (including McpError with a typed ErrorData) into ToolError. The lowlevel call_tool handler then serializes any exception as CallToolResult(isError=true, content=[TextContent(message)]) via _make_error_result. There is no public path that surfaces JSON-RPC -32000 with a structured data.busy_owner field from a tool call site — the SDK's wire contract for tool errors is the isError=true flag plus text content. The busy-owner value lives in the text content (truncated session_id for mcp_session: collisions, full string for cloud_assignment). Unknown-device and other semantic errors are returned as normal ok=False payloads inside a successful CallToolResult(isError=false) (see api/mcp.py::call_with_semantic_errors).

8. Testing Strategy

8.1 单元测试

  • McpBusyTracker(~10 用例):acquire/renew/release/busy_device_ids/snapshot/wait_until_usable 的成功/冲突/并发/TTL sweep
  • McpTokenStore(~6 用例):首次创建/二次复用/并发 atomic/verify timing-safe/损坏 JSON 抛错/不可写抛错
  • BearerAuthMiddleware(~5 用例):无 header/错误 token/正确 token/非 Bearer scheme/大小写
  • 包装层 decorator(~10 用例,重点):
    • 显式 manager= 传入 → 工具调用成功(回归 D12 地雷
    • manager=None → 包装层断言失败(新约束
    • cloud 占用 → -32000 busy(mock AgentStatusTracker)
    • 另一 MCP session 占用 → -32000 busy
    • 同 session 已持有 → renew 后通过
    • acquire/renew 调用顺序
    • 底层 DeviceNotFoundError-32602
    • 底层 DriverError-32000
    • list_devices 不 busy check 但走 display status
    • device_status 同上

8.2 集成测试

  • Mount wiring(~4 用例):/mcp mount 存在、不继承 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 客户端模拟(mcp SDK 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.py 9 处 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-token CLI(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:110tests/test_mcp.py:13,56tests/test_skill_catalog_e2e.py:287)都已显式传 manager=manager,改动面为 0。
  • Q2mcp-token CLI 是否需要鉴权?→ MVP 不鉴权,假定能访问宿主机的操作者可信(与 setup 子命令同款)。后续可加 --password 校验 local_account。
  • Q3:MCP 工具调用是否需要 wall-clock 超时?→ MVP 不加,依赖底层超时;如有"Hermes 调用挂死"报告再加。
  • Q4:心跳扩展是 Alembic migration 还是仅 schema 字段?→ MVP 仅 transient 字段(heartbeat 接收 → scheduler 用 → 丢弃),无需 migration。
  • Q5(已答,2026-07-21 fix wave):TTL 从 60s 降到 20s,因为 mcp SDK 1.28.1 没有 per-session shutdown callback(见 §6.5)。20s 仍能容许正常 operator 暂停,但能在一次 30s 心跳窗口内回收崩盘 session 的锁;新 test_default_ttl_is_20_secondstest_default_ttl_recovers_dead_session_within_one_window 锁定该值。如有"Hermes 长操作横跨 20s 静默"报告再调高。

11. Risks

  • R1:Cloud 心跳窗口期(30s)冲突。缓解:AssignmentExecutor 启动前 fail-fast。残留:cloud 可能基于过期心跳派任务、host-agent fail、cloud 重试——浪费 attempt 配额。接受
  • R2:Hermes 长时间占用设备导致 cloud 任务反复 fail。MVP 无自动缓解;用户手动管控;未来启用 wait_until_usable接受
  • R3:Hermes 崩溃后 ≤20s 设备不可用(TTL 兜底,但窗口存在)。窗口仍 ≤30s 心跳间隔,cloud 侧下次心跳能学到。接受,记入 Q5
  • R4(已消除)tool_handlers 签名改动影响面。Grep 确认 0 调用方依赖 None 默认。
  • R5:FastMCP session_id 提取依赖 mcp SDK 内部 API。缓解:e2e 测试覆盖;SDK 升级 CI 能及时暴露。
  • R6mcp SDK 需作为 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 阶段细化)

  1. api/mcp.py::tool_handlers 签名收紧(D12)+ 同步更新调用方 docstring/类型
  2. McpTokenStore + 单测
  3. McpBusyTracker(含 wait_until_usable)+ 单测
  4. BearerAuthMiddleware + 单测
  5. build_mcp_server 包装层 + 单测(覆盖 D12 回归)
  6. create_console_app mount wiring + 集成测试
  7. create_application 装配
  8. 心跳 payload 扩展 + cloud scheduler 改动 + 集成测试
  9. AssignmentExecutor fail-fast 检查 + 测试
  10. CLI mcp-token 子命令
  11. Dashboard 状态行
  12. 文档(MCP_INTEGRATION.md + MACOS_IPHONE_SETUP.md
  13. e2e 测试(可选)
  14. 全量非集成测试回归 + ruff + compileall + openspec strict validation