From f2006d9032232c70e8c15e33f5083fbfad9464df Mon Sep 17 00:00:00 2001 From: Mario Date: Sun, 17 May 2026 17:39:39 -0400 Subject: [PATCH] fix: defer device acquisition so MCP server starts without attached device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, AdbDeviceManager.__init__ would call sys.exit(1) at module load time if no Android device was connected via adb, which caused Claude / Cursor / any MCP host to mark the server as "failed to connect" and never retry — even when the device became available seconds later. The class already supports exit_on_error=False which raises RuntimeError instead of exiting. This change uses it: the server tries device init at startup, logs a one-line warning if no device is present, then proceeds to start the MCP stdio transport. Tool calls re-attempt device acquisition on first use, so the server self-heals as soon as a device appears via `adb`. Behavior matrix: device present at startup → tools work immediately (unchanged) no device at startup, plug in later, then call tool → tool succeeds (NEW — was: server dead) no device at startup, no plug-in, call tool → tool returns clear error "Android device unavailable: ... Connect a device via adb and retry." (NEW — was: server dead, no error to caller) Smoke verified locally: server stays alive in MCP stdio loop with no device attached (previously exited with code 1 immediately). Resolves: MCP host showing android-mcp as failed when no device attached. Co-Authored-By: Claude Opus 4.7 (1M context) --- server.py | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/server.py b/server.py index fe1998c..c7e623e 100644 --- a/server.py +++ b/server.py @@ -43,9 +43,37 @@ f"Config file {CONFIG_FILE} not found, using auto-selection for device") # Initialize MCP and device manager -# AdbDeviceManager will handle auto-selection if device_name is None +# AdbDeviceManager will handle auto-selection if device_name is None. +# Device acquisition is deferred — the MCP server starts even when no Android +# device is currently attached, and each tool re-attempts device acquisition +# on first call. This prevents a transient adb-disconnect (e.g., cable unplug, +# device sleep, USB hub power cycle) from crashing the server at startup. mcp = FastMCP("android") -deviceManager = AdbDeviceManager(device_name) +deviceManager: AdbDeviceManager | None = None + +try: + deviceManager = AdbDeviceManager(device_name, exit_on_error=False) +except RuntimeError as e: + print( + f"Android device unavailable at startup: {e} " + f"MCP server will start in deferred-init mode; tools will re-attempt " + f"device acquisition on first call.", + file=sys.stderr, + ) + + +def _get_device_manager() -> AdbDeviceManager: + """Return the live device manager, re-attempting acquisition if needed.""" + global deviceManager + if deviceManager is None: + try: + deviceManager = AdbDeviceManager(device_name, exit_on_error=False) + except RuntimeError as e: + raise RuntimeError( + f"Android device unavailable: {e} " + f"Connect a device via `adb` and retry the tool call." + ) + return deviceManager @mcp.tool() @@ -55,7 +83,7 @@ def get_packages() -> str: Returns: str: A list of all installed packages on the device as a string """ - result = deviceManager.get_packages() + result = _get_device_manager().get_packages() return result @@ -67,7 +95,7 @@ def execute_adb_shell_command(command: str) -> str: Returns: str: The output of the ADB command """ - result = deviceManager.execute_adb_shell_command(command) + result = _get_device_manager().execute_adb_shell_command(command) return result @@ -81,7 +109,7 @@ def get_uilayout() -> str: Returns: str: A formatted list of clickable elements with their properties """ - result = deviceManager.get_uilayout() + result = _get_device_manager().get_uilayout() return result @@ -91,7 +119,7 @@ def get_screenshot() -> Image: Returns: Image: the screenshot """ - deviceManager.take_screenshot() + _get_device_manager().take_screenshot() return Image(path="compressed_screenshot.png") @@ -104,7 +132,7 @@ def get_package_action_intents(package_name: str) -> list[str]: Returns: list[str]: A list of all non-data actions from the Activity Resolver Table for the package """ - result = deviceManager.get_package_action_intents(package_name) + result = _get_device_manager().get_package_action_intents(package_name) return result