feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization

- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
somew1nd committed 2026-08-18 13:59:27 -07:00
1 parent 78e478f502
commit 9a5aaf99b7
249 files changed
+37226 -9339

No files matched your search

-157
View File
@@ -1,157 +0,0 @@
# 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.
"""Benchmark and evaluation command (artemis bench)."""
import asyncio
from pathlib import Path
import subprocess
import sys
from typing import Annotated
from artemis.config import initialize_llm_config
from artemis.sdk import Agent
from artemis.sdk.builders import Builders
from artemis.sdk.types.task import AgentProfile
from artemis.utils.logger import get_logger
import typer
logger = get_logger(__name__)
def check_and_start_emulator(adb_path: str = "adb") -> bool:
"""Checks if Android emulator is running; optionally starts it."""
try:
res = subprocess.run([adb_path, "devices"], capture_output=True, text=True, timeout=5)
for line in res.stdout.splitlines():
if "emulator-" in line and "device" in line:
return True
except Exception:
pass
return False
async def run_benchmark(
task_name: str | None = None,
profile_name: str = "pro",
console_port: int = 5554,
adb_path: str = "adb",
) -> None:
"""Runs AndroidWorld benchmark task evaluations."""
# Add android_world-main if present
workspace_root = Path(__file__).resolve().parent.parent.parent.parent.parent
aw_path = workspace_root / "android_world-main"
if aw_path.exists() and str(aw_path) not in sys.path:
sys.path.insert(0, str(aw_path))
try:
from android_world import registry
from android_world.env import env_launcher
except ImportError:
logger.error("AndroidWorld benchmark suite is not installed or available in path.")
raise typer.Exit(1)
task_registry = registry.TaskRegistry()
aw_registry = task_registry.get_registry(task_registry.ANDROID_WORLD_FAMILY)
if task_name and task_name not in aw_registry:
logger.error(f"Task '{task_name}' not found in AndroidWorld registry.")
available = sorted(list(aw_registry.keys()))
logger.info(f"Available tasks ({len(available)}): {', '.join(available[:10])}...")
raise typer.Exit(1)
logger.info(f"Initializing AndroidWorld environment on console port {console_port}...")
env = env_launcher.load_and_setup_env(
console_port=console_port,
emulator_setup=False,
adb_path=adb_path,
)
llm_config = initialize_llm_config()
profile = AgentProfile(name="default", llm_config=llm_config)
config = Builders.AgentConfig.with_default_profile(profile).build()
agent = Agent(config=config)
await agent.init()
tasks_to_run = [task_name] if task_name else list(aw_registry.keys())
try:
for t_name in tasks_to_run:
task_type = aw_registry[t_name]
params = task_type.generate_random_params()
task_instance = task_type(params)
logger.info(f"\n🎯 [Benchmark] Starting: {t_name} | Goal: {task_instance.goal}")
task_instance.initialize_task(env)
result = await agent.run_task(
goal=task_instance.goal,
name=f"bench_{t_name}",
profile=profile_name,
)
success = task_instance.is_successful(env)
logger.info(f"🏆 Task '{t_name}' Result: {result} | Benchmark Verified: {success}")
finally:
await agent.clean()
env.close()
def bench_command(
task: Annotated[
str | None,
typer.Option(
"--task",
"-t",
help="Specific AndroidWorld task name to evaluate (e.g. Clock_1, Calculator_1).",
),
] = None,
profile: Annotated[
str,
typer.Option(
"--profile",
"-p",
help="Agent profile to evaluate ('flash' or 'pro').",
),
] = "pro",
adb_path: Annotated[
str,
typer.Option(
"--adb-path",
help="Path to ADB executable.",
),
] = "adb",
console_port: Annotated[
int,
typer.Option(
"--port",
help="Emulator console port.",
),
] = 5554,
) -> None:
"""Run AndroidWorld benchmark task evaluations against the agent."""
if not check_and_start_emulator(adb_path=adb_path):
typer.secho(
f"Warning: No active emulator detected on port {console_port}. Please start an emulator.",
fg=typer.colors.YELLOW,
)
asyncio.run(
run_benchmark(
task_name=task,
profile_name=profile,
console_port=console_port,
adb_path=adb_path,
)
)
+48 -20
View File
@@ -14,7 +14,7 @@
"""System, environment, and device diagnostics (artemis doctor)."""
import shutil
import platform as sys_platform
import subprocess
import sys
@@ -26,16 +26,21 @@ from artemis.config import (
parse_llm_config,
settings,
)
from artemis.platform import OSType, platform
from artemis.toolchain import toolchain
from artemis.utils.ocr_api import is_ocr_configured
def doctor_command() -> None:
"""Run diagnostics to inspect system dependencies, device connectivity, and configuration."""
console = Console()
console.print()
os_name = sys_platform.system()
arch_name = sys_platform.machine()
console.print(
Panel(
"[bold cyan]☕ Artemis System & Environment Doctor[/bold cyan]\n"
"[dim]Checking prerequisites for autonomous mobile testing...[/dim]",
f"[bold cyan]☕ Artemis System & Environment Doctor[/bold cyan]\n"
f"[dim]Platform: {os_name} ({arch_name}) | PAL: {platform.os_type.value}[/dim]",
expand=False,
)
)
@@ -53,7 +58,7 @@ def doctor_command() -> None:
table.add_row(
"Python Runtime",
"[bold green]✔ OK[/bold green]",
f"Python {py_ver} (64-bit)",
f"Python {py_ver} ({arch_name}, 64-bit)",
)
else:
all_passed = False
@@ -64,7 +69,7 @@ def doctor_command() -> None:
)
# 2. ADB Installation & Server
adb_path = shutil.which("adb")
adb_path = toolchain.resolve("adb")
if adb_path:
try:
res = subprocess.run(
@@ -72,6 +77,8 @@ def doctor_command() -> None:
capture_output=True,
text=True,
timeout=5,
encoding="utf-8",
errors="replace",
)
adb_info = res.stdout.splitlines()[0] if res.stdout else "Installed"
table.add_row(
@@ -81,10 +88,17 @@ def doctor_command() -> None:
table.add_row("Android ADB", "[bold green]✔ OK[/bold green]", f"{adb_path}")
else:
all_passed = False
hint = (
"Run: winget install Google.PlatformTools"
if platform.os_type == OSType.WINDOWS
else "Run: brew install android-platform-tools"
if platform.os_type == OSType.MACOS
else "Run: sudo apt-get install -y adb"
)
table.add_row(
"Android ADB",
"[bold red]✖ Missing[/bold red]",
"adb not found in PATH. Install Android Platform-Tools.",
f"adb not found in PATH or Android SDK. {hint}",
)
# 3. Connected Android Devices
@@ -96,6 +110,8 @@ def doctor_command() -> None:
capture_output=True,
text=True,
timeout=5,
encoding="utf-8",
errors="replace",
)
lines = [line_str.strip() for line_str in res.stdout.splitlines() if line_str.strip()]
for line in lines[1:]: # skip 'List of devices attached'
@@ -123,7 +139,7 @@ def doctor_command() -> None:
table.add_row(
"Android Devices",
"[bold yellow]⚠ Unauthorized[/bold yellow]",
"Device detected but unauthorized. Check phone screen for USB Debugging permission prompt.",
"Device detected but unauthorized. Check phone screen for USB Debugging prompt.",
)
else:
table.add_row(
@@ -160,11 +176,11 @@ def doctor_command() -> None:
table.add_row(
"LLM Providers",
"[bold red]✖ Missing[/bold red]",
"No LLM key found in .env. Run [bold cyan]artemis init[/bold cyan] to configure in 10 seconds.",
"No LLM key found in .env. Run [bold cyan]artemis init[/bold cyan] to configure.",
)
# 5. Vision OCR Key Check
if ocr_key:
# 5. Vision OCR Key Check (Optional)
if is_ocr_configured() and ocr_key:
val = ocr_key.get_secret_value()
masked = f"{val[:6]}...{val[-4:]}" if len(val) > 10 else "***"
table.add_row(
@@ -175,8 +191,8 @@ def doctor_command() -> None:
else:
table.add_row(
"Vision OCR",
"[bold yellow]⚠ Missing[/bold yellow]",
"OCR_API_KEY / API_KEY not set. Perception fast path will use fallback.",
"[bold cyan]○ Optional[/bold cyan]",
"OCR_API_KEY is not set. Perception operates in pure XML layout mode.",
)
# 6. Configuration File Health
@@ -197,33 +213,45 @@ def doctor_command() -> None:
)
# 7. Video Recording Tools (FFmpeg & scrcpy)
ffmpeg_path = shutil.which("ffmpeg")
scrcpy_path = shutil.which("scrcpy")
ffmpeg_path = toolchain.resolve("ffmpeg")
scrcpy_path = toolchain.resolve("scrcpy")
if ffmpeg_path:
table.add_row(
"FFmpeg Tools",
"[bold green]✔ Installed[/bold green]",
f"{ffmpeg_path} (Video encoding/trimming ready)",
"FFmpeg Tools", "[bold green]✔ Installed[/bold green]", f"{ffmpeg_path} (Video ready)"
)
else:
hint = (
"winget install Gyan.FFmpeg"
if platform.os_type == OSType.WINDOWS
else "brew install ffmpeg"
if platform.os_type == OSType.MACOS
else "sudo apt-get install -y ffmpeg"
)
table.add_row(
"FFmpeg Tools",
"[dim]⚪ Optional[/dim]",
"Not found. Install ffmpeg for video compression and audio analysis.",
f"Not found. ({hint})",
)
if scrcpy_path:
table.add_row(
"Screen Recorder",
"[bold green]✔ Installed[/bold green]",
f"{scrcpy_path} (Live screen recording ready)",
f"{scrcpy_path} (Live stream ready)",
)
else:
hint = (
"winget install Genymobile.scrcpy"
if platform.os_type == OSType.WINDOWS
else "brew install scrcpy"
if platform.os_type == OSType.MACOS
else "sudo apt-get install -y scrcpy"
)
table.add_row(
"Screen Recorder",
"[dim]⚪ Optional[/dim]",
"scrcpy not found. Install scrcpy for --with-video-recording-tools.",
f"scrcpy not found. ({hint})",
)
console.print(table)
+109 -7
View File
@@ -14,13 +14,40 @@
"""Model Context Protocol (MCP) server command (artemis mcp)."""
import json
from typing import Annotated
from mcp_server.base import mcp as agent_mcp
from mcp_server.utils import env_utils
from artemis.mcp.adb_server import mcp as adb_mcp
from artemis.utils.logger import get_logger
from rich.console import Console
from rich.syntax import Syntax
import typer
logger = get_logger(__name__)
console = Console()
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
"""Generates MCP configuration dictionary for the specified client."""
config_body = {
"command": python_exe,
"args": ["-m", "mcp_server"],
"cwd": project_root,
"env": {
"PYTHONUNBUFFERED": "1",
},
}
if client == "cursor":
return {"mcpServers": {"artemis": config_body}}
elif client in ("claude", "claude_code", "claude_desktop"):
return {"mcpServers": {"artemis": config_body}}
elif client == "openclaw":
return {"plugins": {"artemis_mcp": {"enabled": True, "type": "mcp", "server": config_body}}}
else: # generic
return {"mcpServers": {"artemis": config_body}}
def mcp_command(
@@ -29,14 +56,89 @@ def mcp_command(
typer.Option(
"--type",
"-t",
help="Type of MCP server to start ('adb' or 'xml').",
help="Type of MCP server to start: 'agent' (default, universal IDE mobile agent), 'adb' (raw adb), 'xml' (xml fuzzy search).",
),
] = "adb",
] = "agent",
transport: Annotated[
str,
typer.Option(
"--transport",
help="MCP transport protocol ('stdio' or 'sse').",
),
] = "stdio",
host: Annotated[
str,
typer.Option(
"--host",
help="Host address when running with SSE transport.",
),
] = "127.0.0.1",
port: Annotated[
int,
typer.Option(
"--port",
"-p",
help="Port number when running with SSE transport.",
),
] = 8001,
generate_config: Annotated[
str | None,
typer.Option(
"--generate-config",
"-g",
help="Output ready-to-use MCP configuration JSON for 'cursor', 'claude', 'openclaw', or 'all'.",
),
] = None,
) -> None:
"""Launch a ARTEMIS Model Context Protocol (MCP) server."""
if server_type.lower() == "adb":
logger.info("Starting Artemis ADB MCP Server over stdio...")
adb_mcp.run(transport="stdio")
"""Launch or configure the ARTEMIS Model Context Protocol (MCP) server."""
project_root = env_utils.get_project_root()
python_exe = env_utils.resolve_python_executable(project_root)
if generate_config:
client = generate_config.lower()
if client == "all":
all_configs = {
"cursor (.cursor/mcp.json)": _get_config_snippet(
"cursor", python_exe, project_root
),
"claude (claude_desktop_config.json)": _get_config_snippet(
"claude", python_exe, project_root
),
"openclaw (openclaw.json)": _get_config_snippet(
"openclaw", python_exe, project_root
),
}
json_str = json.dumps(all_configs, indent=2)
else:
snippet = _get_config_snippet(client, python_exe, project_root)
json_str = json.dumps(snippet, indent=2)
syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False)
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
console.print(syntax)
raise typer.Exit(0)
st = server_type.lower()
if st in ("agent", "mobile", "artemis", "default"):
logger.info(f"Starting Artemis Mobile Agent MCP Server over {transport}...")
if transport.lower() == "sse":
agent_mcp.run(transport="sse", host=host, port=port)
else:
agent_mcp.run(transport="stdio")
elif st == "adb":
logger.info(f"Starting Artemis ADB MCP Server over {transport}...")
if transport.lower() == "sse":
adb_mcp.run(transport="sse", host=host, port=port)
else:
adb_mcp.run(transport="stdio")
elif st == "xml":
from artemis.mcp.xml_search_server import mcp as xml_mcp
logger.info(f"Starting Artemis XML Fuzzy Search MCP Server over {transport}...")
if transport.lower() == "sse":
xml_mcp.run(transport="sse", host=host, port=port)
else:
xml_mcp.run(transport="stdio")
else:
logger.error(f"Unsupported MCP server type: {server_type}")
logger.error(f"Unsupported MCP server type: {server_type}. Use 'agent', 'adb', or 'xml'.")
raise typer.Exit(1)
+9 -4
View File
@@ -15,6 +15,7 @@
"""Server and cloud proxy commands (artemis server)."""
import http.server
import os
import socketserver
import subprocess
import time
@@ -50,7 +51,7 @@ def get_identity_token() -> str:
class CloudRunProxyHandler(http.server.BaseHTTPRequestHandler):
target_url: str = "https://artemis-cloud-brain-971929466682.us-central1.run.app"
target_url: str = os.getenv("ARTEMIS_CLOUD_URL", "http://127.0.0.1:8000")
def log_message(self, format, *args):
logger.info(f"[{self.log_date_time_string()}] {format % args}")
@@ -81,7 +82,9 @@ class CloudRunProxyHandler(http.server.BaseHTTPRequestHandler):
token = get_identity_token()
if token:
headers["Authorization"] = f"Bearer {token}"
headers["X-Artemis-Token"] = "alice-token"
tenant_token = os.getenv("ARTEMIS_TENANT_TOKEN")
if tenant_token:
headers["X-Artemis-Token"] = tenant_token
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else None
@@ -119,8 +122,10 @@ def web_proxy_command(
port: Annotated[int, typer.Option("--port", "-p", help="Local port to bind.")] = 8080,
target: Annotated[
str,
typer.Option("--target", "-t", help="Target Cloud Run URL."),
] = "https://artemis-cloud-brain-971929466682.us-central1.run.app",
typer.Option(
"--target", "-t", help="Target Artemis Cloud URL.", envvar="ARTEMIS_CLOUD_URL"
),
] = "http://127.0.0.1:8000",
) -> None:
"""Start the local authenticated proxy for ARTEMIS Cloud Web Dashboard."""
CloudRunProxyHandler.target_url = target
+114
View File
@@ -0,0 +1,114 @@
# 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.
"""UI Launch and Web Console CLI Command (artemis ui)."""
import threading
import time
from typing import Annotated
import urllib.request
import webbrowser
from rich.console import Console
from rich.panel import Panel
import typer
def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float = 15.0) -> None:
"""Poll the UI server until it responds, then launch the user's default browser."""
start = time.time()
while not stop_event.is_set() and (time.time() - start < timeout):
try:
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-HealthCheck/1.0"})
with urllib.request.urlopen(req, timeout=1.5) as resp:
if resp.status == 200:
time.sleep(0.3)
webbrowser.open(url)
return
except Exception:
time.sleep(0.4)
def ui_command(
host: Annotated[
str,
typer.Option("--host", "-h", help="Bind host address for the UI server."),
] = "0.0.0.0",
port: Annotated[
int,
typer.Option("--port", "-p", help="Port to run the unified UI server on."),
] = 8000,
open_browser: Annotated[
bool,
typer.Option(
"--open/--no-open",
help="Automatically open the Showcase UI in default web browser.",
),
] = True,
reload: Annotated[
bool,
typer.Option(
"--reload/--no-reload",
help="Enable uvicorn live auto-reload (for development).",
),
] = False,
) -> None:
"""Launch the unified Artemis Showcase UI & Admin Console in one click."""
console = Console()
local_url = f"http://localhost:{port}"
console.print()
msg = (
f"[bold cyan]✨ Artemis Autonomous Mobile Agent UI[/bold cyan]\n\n"
f"📱 [bold green]Showcase UI & Onboarding:[/bold green] [cyan]{local_url}[/cyan]\n"
f"🛠️ [bold magenta]Admin Debug Console:[/bold magenta] [cyan]{local_url}/admin[/cyan]\n"
f"📖 [dim]API Documentation:[/dim] [dim]{local_url}/docs[/dim]\n\n"
f"[dim]Press Ctrl+C to stop the server.[/dim]"
)
console.print(Panel(msg, title="🚀 Web Server Active", border_style="cyan", expand=False))
# Ensure .env exists in workspace root
from artemis.config.paths import ROOT_DIR
env_file = ROOT_DIR / ".env"
env_example = ROOT_DIR / ".env.example"
if not env_file.exists():
if env_example.exists():
try:
env_file.write_text(env_example.read_text(encoding="utf-8"), encoding="utf-8")
except Exception:
pass
else:
try:
env_file.touch()
except Exception:
pass
stop_event = threading.Event()
if open_browser:
t = threading.Thread(
target=_poll_and_open_browser,
args=(local_url, stop_event),
daemon=True,
)
t.start()
try:
import uvicorn
from apps.admin_console.server import app
uvicorn.run(app, host=host, port=port, reload=reload)
except KeyboardInterrupt:
console.print("\n[yellow]🛑 UI Server stopped.[/yellow]")
finally:
stop_event.set()