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>
126 lines
4.9 KiB
Markdown
126 lines
4.9 KiB
Markdown
# 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 **20 seconds of inactivity**
|
|
(the `McpBusyTracker` default TTL). The mcp SDK 1.28.1 does not expose
|
|
a per-session shutdown callback, so a clean Hermes disconnect is also
|
|
recovered via the 20s TTL sweep — see the implementation note in
|
|
spec §6.5. Cloud assignments hold theirs until the assignment
|
|
terminates.
|
|
- The cloud scheduler is told about MCP-held devices via the heartbeat
|
|
`mcp_busy_device_ids` field, 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 with `failure_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
|
|
|
|
The mcp SDK 1.28.1 forces tool errors into `CallToolResult(isError=true,
|
|
content=[TextContent(message)])` — there is no public path that surfaces
|
|
JSON-RPC `-32000` with a structured `data.busy_owner` field from a tool
|
|
call site. The busy-owner value lives inside the text content (full
|
|
string for `cloud_assignment`, truncated session_id prefix for
|
|
`mcp_session:` collisions).
|
|
|
|
| Condition | JSON-RPC envelope | `result.content[0].text` |
|
|
|---|---|---|
|
|
| Missing/wrong bearer token | HTTP 401 (transport-level) | `{"error": "invalid token"}` + `WWW-Authenticate: Bearer` |
|
|
| Device busy (cloud) | `result.isError = true` | `"device <X> is busy (held by cloud assignment)"` |
|
|
| Device busy (other MCP) | `result.isError = true` | `"device <X> is busy (held by mcp_session:<8-char-prefix>)"` |
|
|
| Unknown device | `result.isError = false` | JSON `{"ok": false, "error": "device not found: <X>"}` |
|
|
| Tool error | `result.isError = true` | `"Error executing tool <name>: <original-message>"` |
|
|
|
|
## Troubleshooting
|
|
|
|
- **`list_devices` returns `[]`**: no devices registered. Use the local
|
|
console at `http://127.0.0.1:8765/` to add one (Login → Devices).
|
|
- **`device X is busy` even 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_usable` MCP 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.
|