- 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).
462 lines
14 KiB
Python
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()
|