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>
This commit is contained in:
2026-07-21 16:25:35 +08:00
co-authored by Claude Opus 4.6
parent e69cea0245
commit 70e0624a47
8 changed files with 207 additions and 35 deletions
+18 -8
View File
@@ -65,8 +65,11 @@ All 11 device tools from `api/mcp.py`:
- 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
- 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
@@ -86,13 +89,20 @@ override.
## Error responses
| Condition | HTTP / JSON-RPC | Body |
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 | `{"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 |
| 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