Files
Artemis-Eapil/artemis/mcp/adb_server.py
T
weilinliu55 cbdca1497c feat: extract in-process MCP action layer, concurrent task dispatch, and spawn/reconcile hardening
- Extract device action execution into artemis/mcp action layer:
  typed ActionResult, capability manifest with startup validation,
  AdbActuator as the single physical implementation, in-memory
  ActionSession (replaces stdio subprocess MCP in validator), and
  McpActionExecutor preserving the legacy executor contract.
  adb_server keeps its external stdio/SSE surface unchanged and now
  reuses the actuator implementation; import-time side effects moved
  into configure_stdio_mode().
- Capability-gated prompt assembly (prompt_assembly.py): tool enums and
  teaching segments render per available backend tools; full-set output
  is byte-identical to the previous static text (locked by tests).
- Admin console task queue: serial worker replaced by a per-device
  concurrent dispatcher (ARTEMIS_CONCURRENCY_MODE=global restores
  serial semantics), per-session cancellation, strict device binding on
  enqueue, unified stdio piping with tee to trace stdout.log, and
  shutdown cleanup of all active runs. Fix _pid_exists exception order
  so PermissionError is treated as alive.
- mcp_server reliability: spawn watchdog kills runners stuck before
  interpreter startup and releases device reservations; task state
  reconcile uses psutil liveness, prefers runner-reported pids, and
  adds a 45s startup grace to stop false failure reports on Windows;
  conversation_id is now optional end-to-end; explicit device_serial is
  validated at submission.
- CLI install now writes ~/.claude/rules/artemis.md for Claude Code and
  migrates/removes the legacy CLAUDE.md injection block; ~/.claude.json
  is never rewritten when it fails to parse.
- Tests: Windows-portable stdio lifecycle and path assertions; new
  suites for action layer, prompt assembly, spawn watchdog, and task
  reconcile (895 passed, 3 skipped).
2026-08-27 18:25:27 -07:00

462 lines
14 KiB
Python

# Copyright 2026 Google LLC
#
# 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.
import asyncio
import json
import logging
import os
from pathlib import Path
import sys
from typing import Any
# Ensure repository root is in sys.path when executed directly or via MCP runner
_REPO_ROOT = str(Path(__file__).resolve().parent.parent.parent)
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
# pylint: disable=wrong-import-position
from adbutils import AdbClient
from mcp.server.fastmcp import Context, FastMCP
try:
from mcp.server.fastmcp.server import Settings as FastMCPSettings
FastMCPSettings.model_rebuild()
except Exception:
pass
from artemis.clients.ui_automator_client import UIAutomatorClient
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.platform import platform
from artemis.utils.app_launch_utils import launch_app_with_retries
logger = logging.getLogger(__name__)
def configure_stdio_mode() -> None:
"""Applies process-wide settings required when serving MCP over stdio.
These are destructive to the host process (console logging is removed and the
DataEngine IPC port is cleared), so they must only run when this module owns the
process -- i.e. from the ``__main__`` block. Importing this module for its tools
or for :func:`_get_controller` must leave the caller's logging and environment
untouched.
"""
# Redirect stdio to prevent logs from breaking MCP JSON-RPC protocol and causing deadlocks.
try:
from artemis.config import settings
log_path = Path(settings.TRACES_PATH) / "mcp_server.log"
except Exception:
log_path = Path("traces") / "mcp_server.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
for name in ["artemis", __name__]:
log_instance = logging.getLogger(name)
log_instance.setLevel(logging.DEBUG)
log_instance.propagate = False
# Remove standard streams handlers
log_instance.handlers = [
h for h in log_instance.handlers if not isinstance(h, logging.StreamHandler)
]
fh = logging.FileHandler(log_path, encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s | %(name)s | %(levelname)s | %(message)s"))
log_instance.addHandler(fh)
logger.info("MCP Server Python process fully started. Configuring FastMCP service...")
# Avoid concurrent SQLite WAL locks by blocking child processes from connecting
# to the primary DataEngine.
os.environ["ARTEMIS_IPC_PORT"] = ""
# Create minimal MCP server
mcp = FastMCP("Android_ADB_Controller")
_GLOBAL_CONTROLLER = None
_CONTROLLERS: dict[str, Any] = {}
def _get_controller(device_serial: str | None = None):
"""Lazy-load device controller on-demand, caching per device serial."""
global _GLOBAL_CONTROLLER, _CONTROLLERS
target_serial = device_serial or os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL")
if target_serial and target_serial in _CONTROLLERS:
return _CONTROLLERS[target_serial]
if not target_serial and _GLOBAL_CONTROLLER is not None:
return _GLOBAL_CONTROLLER
logger.info("Initializing lazy device controller...")
if os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
logger.info(
"GCP CLOUD MODE active: initializing CloudMobileDeviceController via UnifiedMobileController..."
)
resolved_serial = target_serial or "cloud_device"
ctx = ArtemisContext(
device=DeviceContext(
platform=DevicePlatform.ANDROID,
device_id=resolved_serial,
width=1080,
height=2400,
)
)
controller = UnifiedMobileController(ctx=ctx)
if resolved_serial:
_CONTROLLERS[resolved_serial] = controller
if _GLOBAL_CONTROLLER is None:
_GLOBAL_CONTROLLER = controller
return controller
host = os.environ.get("ADB_HOST", "localhost")
port_str = os.environ.get("ADB_PORT", "5037")
port = int(port_str) if port_str.isdigit() else 5037
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
adb = AdbClient(host=host, port=port)
devices = adb.device_list()
if not devices:
raise Exception(f"No Android devices found at {host}:{port}")
if target_serial:
matched = [d for d in devices if d.serial == target_serial]
if not matched:
raise Exception(
f"Target Android device '{target_serial}' not found at"
f" {host}:{port} among available devices."
)
device = matched[0]
else:
device = devices[0]
device_id = device.serial
ui_client = UIAutomatorClient(device_id=device_id)
try:
ui_data = ui_client.get_screen_data()
width, height = ui_data.width, ui_data.height
except Exception as e:
logger.warning(f"Failed initial screen data check, using defaults: {e}")
width, height = 1080, 2400
ctx = ArtemisContext(
trace_id="mcp-session",
device=DeviceContext(
host_platform=platform.os_type.name,
mobile_platform=DevicePlatform.ANDROID,
device_id=device_id,
device_width=width,
device_height=height,
),
adb_client=adb,
ui_adb_client=ui_client,
)
controller = UnifiedMobileController(ctx)
if device_id:
_CONTROLLERS[device_id] = controller
if _GLOBAL_CONTROLLER is None:
_GLOBAL_CONTROLLER = controller
logger.info(f"Lazy device controller fully initialized for device: {device_id}")
return controller
# Shared with the in-process actuator layer; this stdio server keeps its legacy
# pixel-space contract for external MCP clients, but the element/focus logic has a
# single implementation in artemis.mcp.actuators.adb.
from artemis.mcp.actuators.adb import ( # noqa: E402 pylint: disable=wrong-import-position
ensure_focus_at_coords as _ensure_focus_at_coords,
find_element_at_coords as _find_element_at_coords,
)
@mcp.tool()
async def tap(
ctx: Context,
coordinates: list[int],
times: int = 1,
delay_ms: int = 100,
) -> str:
"""Taps on the screen at coordinates.
'coordinates' is a list [x, y].
'times' is the number of consecutive clicks (default 1).
'delay_ms' is the delay in milliseconds between consecutive clicks (default
100).
"""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
if len(coordinates) != 2:
return "Error: coordinates must be [x, y]"
result = await controller.tap_at(
x=coordinates[0], y=coordinates[1], times=times, delay_ms=delay_ms
)
if result.error:
return f"Error: {result.error}"
return "Success"
@mcp.tool()
async def long_press_on(
ctx: Context,
coordinates: list[int],
duration: int = 1000,
) -> str:
"""Long presses on the screen at coordinates.
'coordinates' is a list [x, y].
'duration' is in milliseconds (default 1000).
"""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
if len(coordinates) != 2:
return "Error: coordinates must be [x, y]"
result = await controller.tap_at(
x=coordinates[0],
y=coordinates[1],
long_press=True,
long_press_duration=duration,
)
if result.error:
return f"Error: {result.error}"
return "Success"
@mcp.tool()
async def swipe(
ctx: Context,
coordinates: list[int],
duration: int = 400,
) -> str:
"""Swipes from start coordinates to end coordinates.
'coordinates' is a list [start_x, start_y, end_x, end_y].
'duration' is in milliseconds (default 400).
Set duration >= 1000 to drag-and-drop. Drag slightly past the target
position to trigger reordering.
"""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
if len(coordinates) != 4:
return "Error: coordinates must be [start_x, start_y, end_x, end_y]"
error = await controller.swipe_coords(
start_x=coordinates[0],
start_y=coordinates[1],
end_x=coordinates[2],
end_y=coordinates[3],
duration=duration,
)
if error:
return f"Error: {error}"
return "Success"
@mcp.tool()
async def back(ctx: Context) -> str:
"""Simulates pressing the system back button."""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
success = await controller.go_back()
return "Success" if success else "Failed"
@mcp.tool()
async def launch_app(ctx: Context, package_name: str) -> str:
"""Launches an application by its Android package name with retries and smart polling."""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
success, error_msg = await launch_app_with_retries(controller.ctx, package_name)
return "Success" if success else f"Failed: {error_msg}"
@mcp.tool()
async def stop_app(ctx: Context, package_name: str) -> str:
"""Force stops an application by its Android package name."""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
success = await controller.terminate_app(package_name)
return "Success" if success else "Failed"
@mcp.tool()
async def open_link(ctx: Context, url: str) -> str:
"""Opens a URL or deep link on the device."""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
success = await controller.open_url(url)
return "Success" if success else "Failed"
@mcp.tool()
async def focus_and_input_text(
ctx: Context,
coordinates: list[int],
text: str,
clear_before_input: bool = False,
) -> str:
"""Focuses on a UI element at coordinates and inputs text.
'coordinates' is a list [x, y] to tap first to gain focus.
'clear_before_input' if True, clears all existing text before typing. If False, appends text at the end of existing content.
'text' supports multi-line content with '\\n'.
"""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
if len(coordinates) != 2:
return "Error: coordinates must be [x, y]"
err = await _ensure_focus_at_coords(controller, coordinates[0], coordinates[1])
if err:
return f"Error focusing element: {err}"
if clear_before_input:
success = await controller.erase_text()
if not success:
return "Failed to clear existing text"
else:
# Move cursor to the end for reliable append
await controller.press_key("123")
success = await controller.type_text(text, clear_existing=False)
return "Success" if success else "Failed"
@mcp.tool()
async def focus_and_clear_text(
ctx: Context,
coordinates: list[int],
) -> str:
"""Focuses on a UI element at coordinates and clears its text content.
'coordinates' is a list [x, y] to tap first to gain focus.
"""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
if len(coordinates) != 2:
return "Error: coordinates must be [x, y]"
err = await _ensure_focus_at_coords(controller, coordinates[0], coordinates[1])
if err:
return f"Error focusing element: {err}"
success = await controller.erase_text()
return "Success" if success else "Failed"
@mcp.tool()
async def erase_one_char(ctx: Context) -> str:
"""Erases a single character (simulates Backspace)."""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
success = await controller.erase_text(nb_chars=1)
return "Success" if success else "Failed"
@mcp.tool()
async def press_key(ctx: Context, keycode: str) -> str:
"""Presses a specific Android key event (e.g., KEYCODE_ENTER, KEYCODE_HOME)."""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
try:
success = await controller.press_key(keycode)
return "Success" if success else "Failed"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
async def take_screenshot(ctx: Context) -> str:
"""Takes a screenshot of the device screen.
Returns the screenshot as a base64 encoded JPEG string.
"""
try:
controller = _get_controller()
except Exception as e:
return f"Error: Controller lazy initialization failed: {e}"
try:
screenshot_b64 = await controller.take_screenshot()
return screenshot_b64
except Exception as e:
return f"Error: {e}"
@mcp.tool()
async def get_ui_hierarchy(ctx: Context) -> str:
"""Retrieves the current UI elements hierarchy from the device."""
try:
controller = _get_controller()
except Exception as e:
logger.exception("Failed to initialize controller in get_ui_hierarchy")
raise e
try:
elements = await controller.get_ui_elements()
return json.dumps(elements)
except Exception as e:
logger.exception("Failed to get UI elements in get_ui_hierarchy")
raise e
if __name__ == "__main__":
from artemis.runtime import shutdown_awake_service, start_awake_service
configure_stdio_mode()
start_awake_service()
try:
mcp.run(transport="stdio")
finally:
shutdown_awake_service()