From ac78417b3380656487bf13c95940b57f52ab3142 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 21 Jul 2026 11:54:09 -0700 Subject: [PATCH] FastMCP server skeleton & device lifecycle tools for AndroidEnv. PiperOrigin-RevId: 951617872 --- android_env/mcp/__init__.py | 16 ++ android_env/mcp/android_mcp_server.py | 35 ++++ android_env/mcp/android_mcp_tools.py | 211 ++++++++++++++++++++++ android_env/mcp/android_mcp_tools_test.py | 184 +++++++++++++++++++ 4 files changed, 446 insertions(+) create mode 100644 android_env/mcp/__init__.py create mode 100644 android_env/mcp/android_mcp_server.py create mode 100644 android_env/mcp/android_mcp_tools.py create mode 100644 android_env/mcp/android_mcp_tools_test.py diff --git a/android_env/mcp/__init__.py b/android_env/mcp/__init__.py new file mode 100644 index 0000000..472e10c --- /dev/null +++ b/android_env/mcp/__init__.py @@ -0,0 +1,16 @@ +# coding=utf-8 +# Copyright 2026 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AndroidEnv FastMCP Server package.""" diff --git a/android_env/mcp/android_mcp_server.py b/android_env/mcp/android_mcp_server.py new file mode 100644 index 0000000..d7dfdfa --- /dev/null +++ b/android_env/mcp/android_mcp_server.py @@ -0,0 +1,35 @@ +# coding=utf-8 +# Copyright 2026 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastMCP server binary entrypoint for AndroidEnv.""" + +from collections.abc import Sequence +from absl import app +from android_env.mcp import android_mcp_tools +import mcp.server.fastmcp + + +def main(argv: Sequence[str]) -> None: + if len(argv) > 1: + raise app.UsageError("Too many command-line arguments.") + + tools = android_mcp_tools.AndroidMcpTools() + fastmcp_server = mcp.server.fastmcp.FastMCP("AndroidEnv") + tools.register_tools(fastmcp_server) + fastmcp_server.run() + + +if __name__ == "__main__": + app.run(main) diff --git a/android_env/mcp/android_mcp_tools.py b/android_env/mcp/android_mcp_tools.py new file mode 100644 index 0000000..0cec181 --- /dev/null +++ b/android_env/mcp/android_mcp_tools.py @@ -0,0 +1,211 @@ +# coding=utf-8 +# Copyright 2026 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastMCP tool implementation and device state management for AndroidEnv. + +Exposes Android device inspection and interaction capabilities to LLM agents. +""" + +import os +import pathlib +import subprocess +from absl import logging +from android_env import env_interface +from android_env import loader +from android_env.components import config_classes +import mcp.server.fastmcp + + +class AndroidMcpError(Exception): + """Base exception for Android MCP operations.""" + + +class AdbError(AndroidMcpError): + """Raised when ADB commands fail or execution errors occur.""" + + +class DeviceConnectionError(AndroidMcpError): + """Raised when connecting or disconnecting a device fails.""" + + +def get_adb_path() -> str: + """Finds adb binary in runfiles, environment, or system PATH. + + Returns: + Absolute path to adb binary, or 'adb' fallback string. + """ + + android_home = os.environ.get("ANDROID_HOME") + if android_home: + path = pathlib.Path(android_home) / "platform-tools" / "adb" + if path.exists(): + return str(path) + + return "adb" + + +def list_devices() -> list[str]: + """Lists all connected Android devices/emulators via ADB. + + Returns: + List of active device serial numbers (e.g. ['emulator-5554']). + + Raises: + AdbError: If adb command execution fails. + """ + adb_bin = get_adb_path() + try: + result = subprocess.run( + [adb_bin, "devices"], + capture_output=True, + text=True, + check=True, + ) + output = result.stdout + except (subprocess.CalledProcessError, OSError) as e: + raise AdbError(f"Error running adb devices: {e}") from e + + lines = output.strip().splitlines() + + if "List of devices attached" in lines: + header_idx = lines.index("List of devices attached") + device_lines = lines[header_idx + 1 :] + else: + device_lines = lines + + devices = [] + for line in device_lines: + if line.strip(): + parts = line.split() + if len(parts) >= 2 and parts[1] == "device": + devices.append(parts[0]) + return devices + + +class AndroidMcpTools: + """FastMCP tools and device state manager for AndroidEnv. + + Note: + This class manages state (_active_serial, _active_device_env, + _use_adb_direct) for single-session/single-client operation (e.g. stdio + transport with a single agent). It is not thread-safe for concurrent multi- + client HTTP/Sse connections. + """ + + def __init__(self) -> None: + self._active_device_env: env_interface.AndroidEnvInterface | None = None + self._active_serial: str | None = None + self._use_adb_direct: bool = True + + @property + def active_serial(self) -> str | None: + return self._active_serial + + @property + def use_adb_direct(self) -> bool: + return self._use_adb_direct + + @property + def active_device_env(self) -> env_interface.AndroidEnvInterface | None: + return self._active_device_env + + def connect_device( + self, + serial: str | None = None, + use_adb_direct: bool = True, + ) -> str: + """Connects MCP server to an Android device. + + Args: + serial: Target ADB device serial (e.g. 'emulator-5554'). If None, + auto-picks first device. + use_adb_direct: If True (default), routes actions through ADB shell + directly, allowing MCP tools to run concurrently alongside Playground + web UI without closing stream connections. + + Returns: + Status string indicating connection result. + + Raises: + DeviceConnectionError: If device auto-detection or connection fails. + """ + # Gracefully close any existing connection before establishing a new one. + self.disconnect_device() + + target_serial = serial + fetched_devices: list[str] = [] + + if not target_serial: + try: + fetched_devices = list_devices() + except AdbError as e: + raise DeviceConnectionError(f"Failed to list devices: {e}") from e + + if not fetched_devices: + raise DeviceConnectionError( + "Failed to auto-detect device: No devices found." + ) + target_serial = fetched_devices[0] + + self._use_adb_direct = use_adb_direct + + if self._use_adb_direct: + # Verify the serial actually exists before claiming success. + try: + devices = fetched_devices if fetched_devices else list_devices() + if target_serial not in devices: + raise DeviceConnectionError( + f"Device '{target_serial}' not found. Available: {devices}" + ) + except AdbError as e: + raise DeviceConnectionError( + f"Could not verify serial '{target_serial}': {e}" + ) from e + self._active_serial = target_serial + self._active_device_env = None + return ( + f"Connected to device '{target_serial}' via ADB Direct Mode." + " Concurrent Playground streaming active." + ) + + try: + config = config_classes.AndroidEnvConfig() + if isinstance(config.simulator, config_classes.EmulatorConfig): + config.simulator.adb_controller.device_name = target_serial + self._active_device_env = loader.load(config) + self._active_serial = target_serial + return f"Connected to AndroidEnv device '{target_serial}'." + except (RuntimeError, OSError, ValueError) as e: + raise DeviceConnectionError( + f"Failed to connect to AndroidEnv: {e}" + ) from e + + def disconnect_device(self) -> str: + """Disconnects from the active Android device and resets connection state.""" + if self._active_device_env is not None: + try: + self._active_device_env.close() + except (RuntimeError, OSError) as e: + logging.warning("Error closing active device environment: %s", e) + self._active_device_env = None + self._active_serial = None + self._use_adb_direct = True + return "Disconnected from Android device." + + def register_tools(self, fastmcp: mcp.server.fastmcp.FastMCP) -> None: + """Registers bound instance tools with a FastMCP server.""" + fastmcp.add_tool(list_devices) + fastmcp.add_tool(self.connect_device) + fastmcp.add_tool(self.disconnect_device) diff --git a/android_env/mcp/android_mcp_tools_test.py b/android_env/mcp/android_mcp_tools_test.py new file mode 100644 index 0000000..6c050b7 --- /dev/null +++ b/android_env/mcp/android_mcp_tools_test.py @@ -0,0 +1,184 @@ +# coding=utf-8 +# Copyright 2026 DeepMind Technologies Limited. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for Android FastMCP Tools library (CL 1 baseline).""" + +import os +import pathlib +import subprocess +from unittest import mock +from absl.testing import absltest +from android_env import env_interface +from android_env import loader +from android_env.mcp import android_mcp_tools +import mcp.server.fastmcp + + +class AndroidMcpToolsTest(absltest.TestCase): + + def setUp(self): + super().setUp() + self.tools = android_mcp_tools.AndroidMcpTools() + + def test_tool_registration(self): + fastmcp = mcp.server.fastmcp.FastMCP("TestAndroidEnv") + self.tools.register_tools(fastmcp) + tools = fastmcp._tool_manager.list_tools() + tool_names = [t.name for t in tools] + self.assertIn("list_devices", tool_names) + self.assertIn("connect_device", tool_names) + self.assertIn("disconnect_device", tool_names) + + @mock.patch.object(pathlib.Path, "exists", autospec=True, return_value=False) + def test_get_adb_path_fallback(self, unused_mock_exists): + fake_env = {"ANDROID_HOME": "/tmp/fake_sdk"} + with mock.patch.dict(os.environ, fake_env, clear=True): + path = android_mcp_tools.get_adb_path() + self.assertEqual(path, "adb") + + @mock.patch.object(subprocess, "run", autospec=True) + def test_list_devices(self, mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=["adb", "devices"], + returncode=0, + stdout=( + "List of devices" + " attached\nemulator-5554\tdevice\n127.0.0.1:5555\tdevice\n" + ), + ) + devices = android_mcp_tools.list_devices() + self.assertEqual(devices, ["emulator-5554", "127.0.0.1:5555"]) + + @mock.patch.object(subprocess, "run", autospec=True) + def test_list_devices_raises_adb_error(self, mock_run): + mock_run.side_effect = OSError("adb binary not found") + with self.assertRaises(android_mcp_tools.AdbError): + android_mcp_tools.list_devices() + + @mock.patch.object(subprocess, "run", autospec=True) + def test_list_devices_with_banner(self, mock_run): + mock_run.return_value = subprocess.CompletedProcess( + args=["adb", "devices"], + returncode=0, + stdout=( + "* daemon not running; starting now at tcp:5037\n" + "* daemon started successfully\n" + "List of devices attached\n" + "emulator-5554\tdevice\n" + ), + ) + devices = android_mcp_tools.list_devices() + self.assertEqual(devices, ["emulator-5554"]) + + @mock.patch.object( + android_mcp_tools, + "list_devices", + autospec=True, + return_value=["emulator-5554"], + ) + def test_connect_device_adb_direct(self, unused_mock_list): + res = self.tools.connect_device(serial="emulator-5554", use_adb_direct=True) + self.assertIn("ADB Direct Mode", res) + self.assertEqual(self.tools.active_serial, "emulator-5554") + self.assertTrue(self.tools.use_adb_direct) + self.assertIsNone(self.tools.active_device_env) + + @mock.patch.object(loader, "load", autospec=True) + def test_connect_device_loader(self, mock_load): + mock_env_instance = mock.create_autospec( + env_interface.AndroidEnvInterface, instance=True + ) + mock_load.return_value = mock_env_instance + + res = self.tools.connect_device( + serial="emulator-5554", + use_adb_direct=False, + ) + self.assertIn("Connected to AndroidEnv device 'emulator-5554'", res) + self.assertFalse(self.tools.use_adb_direct) + self.assertEqual(self.tools.active_serial, "emulator-5554") + self.assertEqual(self.tools.active_device_env, mock_env_instance) + mock_load.assert_called_once() + + @mock.patch.object( + android_mcp_tools, + "list_devices", + autospec=True, + return_value=["emulator-5554"], + ) + def test_disconnect_device(self, unused_mock_list): + self.tools.connect_device(serial="emulator-5554", use_adb_direct=True) + res = self.tools.disconnect_device() + self.assertEqual(res, "Disconnected from Android device.") + self.assertIsNone(self.tools.active_serial) + + @mock.patch.object( + android_mcp_tools, + "list_devices", + autospec=True, + return_value=["emulator-5554"], + ) + @mock.patch.object(loader, "load", autospec=True) + def test_connect_device_closes_previous(self, mock_load, unused_mock_list): + """Verifies reconnecting closes the prior environment.""" + mock_env = mock.create_autospec( + env_interface.AndroidEnvInterface, instance=True + ) + mock_load.return_value = mock_env + + self.tools.connect_device(serial="emulator-5554", use_adb_direct=False) + self.assertEqual(self.tools.active_device_env, mock_env) + + # Reconnect in ADB direct mode — prior env must be closed. + self.tools.connect_device(serial="emulator-5554", use_adb_direct=True) + mock_env.close.assert_called_once() + self.assertIsNone(self.tools.active_device_env) + + @mock.patch.object( + android_mcp_tools, + "list_devices", + autospec=True, + return_value=["emulator-5554"], + ) + def test_connect_device_bogus_serial(self, unused_mock_list): + """Verifies bogus serial raises DeviceConnectionError.""" + with self.assertRaises(android_mcp_tools.DeviceConnectionError): + self.tools.connect_device(serial="bogus_serial", use_adb_direct=True) + + @mock.patch.object( + android_mcp_tools, + "list_devices", + autospec=True, + return_value=[], + ) + def test_connect_device_empty_devices(self, unused_mock_list): + """Verifies empty device list raises DeviceConnectionError.""" + with self.assertRaises(android_mcp_tools.DeviceConnectionError): + self.tools.connect_device(serial="emulator-5554", use_adb_direct=True) + + @mock.patch.object( + android_mcp_tools, + "list_devices", + autospec=True, + side_effect=android_mcp_tools.AdbError("adb failed"), + ) + def test_connect_device_adb_error_on_verify(self, unused_mock_list): + """Verifies AdbError during verification raises DeviceConnectionError.""" + with self.assertRaises(android_mcp_tools.DeviceConnectionError): + self.tools.connect_device(serial="emulator-5554", use_adb_direct=True) + + +if __name__ == "__main__": + absltest.main()