Fix async/await bugs found by headless E2E test

- Make get_client() sync (was async but did no async work). Callers
  that omitted await silently got a coroutine object instead of the
  SerialClient, causing "'coroutine' object has no attribute 'connect'"
  errors on every tool call.

- Fix esp32_connect: use get_client_or_none() for init check and
  client.event_queue.wait_for() for boot event (wait_event() didn't
  exist on SerialClient).

- Normalise Response.data to dict at parse time — firmware returns
  bare strings on some error paths, which broke .get() calls in tool
  error handlers.

- Remove stale await from ble.py (9 calls) and classic.py (4 calls).

Tested with dual-MCP headless claude session: 26/27 PASS.
This commit is contained in:
Ryan Malloy 2026-02-02 15:54:36 -07:00
parent 0e7b8c2ef5
commit ea22f2f9db
5 changed files with 24 additions and 19 deletions

View File

@ -138,11 +138,15 @@ class Response:
def from_json(cls, line: str) -> Response:
"""Parse a JSON line known to be a response."""
obj = json.loads(line)
raw_data = obj.get("data", {})
# Firmware may return a bare string on some error paths — normalise to dict
if isinstance(raw_data, str):
raw_data = {"error": raw_data}
return cls(
type=MsgType(obj["type"]),
id=obj["id"],
status=Status(obj["status"]),
data=obj.get("data", {}),
data=raw_data if isinstance(raw_data, dict) else {},
)

View File

@ -263,7 +263,7 @@ class SerialClient:
_client: SerialClient | None = None
async def get_client() -> SerialClient:
def get_client() -> SerialClient:
"""Get the singleton serial client.
Raises:

View File

@ -35,7 +35,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_BLE_ENABLE)
return response.data
except Exception as exc:
@ -52,7 +52,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_BLE_DISABLE)
return response.data
except Exception as exc:
@ -76,7 +76,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"enable": enable,
"interval_ms": interval_ms,
@ -108,7 +108,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {}
if name is not None:
params["name"] = name
@ -141,7 +141,7 @@ def register_tools(mcp: FastMCP) -> None:
Dict containing the assigned service handle.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"uuid": uuid,
"primary": primary,
@ -175,7 +175,7 @@ def register_tools(mcp: FastMCP) -> None:
Dict containing the assigned characteristic handle.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"service_handle": service_handle,
"uuid": uuid,
@ -207,7 +207,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {
"char_handle": char_handle,
"value": value,
@ -231,7 +231,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {"char_handle": char_handle}
response = await client.send_command(CMD_GATT_NOTIFY, params)
return response.data
@ -250,7 +250,7 @@ def register_tools(mcp: FastMCP) -> None:
Status dict from the ESP32.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_GATT_CLEAR)
return response.data
except Exception as exc:

View File

@ -30,7 +30,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data from the ESP32 including current BT state.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_CLASSIC_ENABLE)
if response.status == Status.OK:
return {"status": "ok", **response.data}
@ -49,7 +49,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data from the ESP32 confirming BT is disabled.
"""
try:
client = await get_client()
client = get_client()
response = await client.send_command(CMD_CLASSIC_DISABLE)
if response.status == Status.OK:
return {"status": "ok", **response.data}
@ -75,7 +75,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data confirming the new discoverable state.
"""
try:
client = await get_client()
client = get_client()
params = {"discoverable": discoverable, "timeout": timeout}
response = await client.send_command(CMD_CLASSIC_SET_DISCOVERABLE, params)
if response.status == Status.OK:
@ -110,7 +110,7 @@ def register_tools(mcp: FastMCP) -> None:
Response data with the pairing result from the ESP32.
"""
try:
client = await get_client()
client = get_client()
params: dict[str, Any] = {"address": address, "accept": accept}
if passkey is not None:
params["passkey"] = passkey

View File

@ -32,9 +32,10 @@ def register_tools(mcp: FastMCP) -> None:
Connection status including port, baudrate, and whether the
device responded with a boot event.
"""
try:
client = get_client()
except NotConnected:
from ..serial_client import get_client_or_none
client = get_client_or_none()
if client is None:
client = init_client(port=port, baudrate=baudrate)
try:
@ -45,7 +46,7 @@ def register_tools(mcp: FastMCP) -> None:
# Give the ESP32 a moment to send its boot event
boot_received = False
try:
event = await asyncio.wait_for(client.wait_event("boot"), timeout=2.0)
event = await client.event_queue.wait_for(event_name="boot", timeout=2.0)
boot_received = event is not None
except TimeoutError:
pass