From ea22f2f9db0274c32450a9f68a1dd131364d803d Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 2 Feb 2026 15:54:36 -0700 Subject: [PATCH] Fix async/await bugs found by headless E2E test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/mcbluetooth_esp32/protocol.py | 6 +++++- src/mcbluetooth_esp32/serial_client.py | 2 +- src/mcbluetooth_esp32/tools/ble.py | 18 +++++++++--------- src/mcbluetooth_esp32/tools/classic.py | 8 ++++---- src/mcbluetooth_esp32/tools/connection.py | 9 +++++---- 5 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/mcbluetooth_esp32/protocol.py b/src/mcbluetooth_esp32/protocol.py index 07a5b98..1e911d2 100644 --- a/src/mcbluetooth_esp32/protocol.py +++ b/src/mcbluetooth_esp32/protocol.py @@ -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 {}, ) diff --git a/src/mcbluetooth_esp32/serial_client.py b/src/mcbluetooth_esp32/serial_client.py index 343170f..230b784 100644 --- a/src/mcbluetooth_esp32/serial_client.py +++ b/src/mcbluetooth_esp32/serial_client.py @@ -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: diff --git a/src/mcbluetooth_esp32/tools/ble.py b/src/mcbluetooth_esp32/tools/ble.py index a548eec..ea1ca1e 100644 --- a/src/mcbluetooth_esp32/tools/ble.py +++ b/src/mcbluetooth_esp32/tools/ble.py @@ -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: diff --git a/src/mcbluetooth_esp32/tools/classic.py b/src/mcbluetooth_esp32/tools/classic.py index 11bd624..f85ed8a 100644 --- a/src/mcbluetooth_esp32/tools/classic.py +++ b/src/mcbluetooth_esp32/tools/classic.py @@ -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 diff --git a/src/mcbluetooth_esp32/tools/connection.py b/src/mcbluetooth_esp32/tools/connection.py index edf9a93..993e88b 100644 --- a/src/mcbluetooth_esp32/tools/connection.py +++ b/src/mcbluetooth_esp32/tools/connection.py @@ -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