docs(superpowers): add host-agent MCP server design spec

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>
This commit is contained in:
2026-07-21 13:18:10 +08:00
co-authored by Claude Opus 4.6
parent 358f4623ba
commit 3b62195a36
@@ -0,0 +1,432 @@
# 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](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp)),配置长这样:
```yaml
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 锁,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`
```python
@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`
```python
@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
```python
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()`
```python
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 随进程退出消失 | 不需显式清理 |
### 错误返回格式
```json
{
"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 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:110`、`tests/test_mcp.py:13,56`、`tests/test_skill_catalog_e2e.py:287`)都已显式传 `manager=manager`,改动面为 0。
- **Q2**:`mcp-token` CLI 是否需要鉴权?→ 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` 提取依赖 `mcp` SDK 内部 API。缓解:e2e 测试覆盖;SDK 升级 CI 能及时暴露。
- **R6**:`mcp` 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`:
```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