Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ wheels/
# Virtual environments
.venv

screenshots/
screenshots/
appium_profiles.yaml
89 changes: 80 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,90 @@
# Appium FastMCP stdio MCP (Auto-Wait + Retry)

This MCP allows Claude Code to control Android/iOS Appium sessions via stdio.
It supports:

## Features
- Auto-wait
- Retry heuristics
- Screenshot + XML return after each action
- Screenshot + XML after each action
- External Appium profile config (no hardcoded server/capabilities)

## Install
- pip install --upgrade uv
- uv sync
- source .venv/bin/activate # macOS / Linux
- .venv\Scripts\activate # Windows
- `pip install --upgrade uv`
- `uv sync`
- `source .venv/bin/activate` (macOS/Linux)
- `.venv\Scripts\activate` (Windows)

## Run
python server.py
`python server.py`

## Register (without starting manually)
`claude mcp add --transport stdio appium-mcp -- python3 /abs/path/server.py`

---

## Decoupled Appium configuration

### 1) Create your local config

```bash
cp appium_profiles.example.yaml appium_profiles.yaml
```

Then edit `appium_profiles.yaml` for your device farm or local Appium server.

### 2) Start sessions from profiles

- List profiles: `list_appium_profiles(config_path=None)`
- Start by profile: `start_appium_session_with_profile(profile_name, config_path=None, capabilities_override=None)`
- Backward-compatible direct start: `start_appium_session(platform, server_url, capabilities)`

Default tools read profiles by name:
- `start_default_ios_appium_session()` → defaults to `ios-local`
- `start_default_android_appium_session()` → defaults to `android-local`

You can override default profile names with env vars:
- `APPIUM_MCP_DEFAULT_IOS_PROFILE`
- `APPIUM_MCP_DEFAULT_ANDROID_PROFILE`

### 3) Switch config file by environment

```bash
export APPIUM_MCP_CONFIG=/abs/path/to/profiles.yaml
```

`appium_profiles.yaml` is ignored by git, so local UDID/secrets stay uncommitted.

### 4) Advanced profile capabilities

The config loader supports:
- **Profile inheritance** with `extends`
- **Environment placeholder expansion** (e.g. `${IOS_UDID}`)

See `appium_profiles.example.yaml` for both patterns.

---

## Improvements to handle more scenarios

### A. Multi-device and concurrency
- Replace singleton driver with session pool (`session_id -> driver`)
- Require `session_id` in action tools
- Add `list_sessions`, `close_session(session_id)`, and session metadata

### B. Cross-platform UI abstraction
- Add Android XML parser and normalize fields with iOS
- Unified semantic query layer: role/text/state/bounds

### C. Semantic action APIs
- Add high-level tools like `tap_text`, `input_into`, and `assert_text_visible`
- Reduce dependence on brittle raw locators

### D. Observability and reliability
- Structured JSON logs per action (duration, retries, error type)
- Action traces with before/after screenshot + XML change summary
- Stable error codes (`ELEMENT_NOT_FOUND`, `ACTION_TIMEOUT`, etc.)

## Register (Don't require Run)
claude mcp add --transport stdio appium-mcp -- python3 /abs/path/server.py
### E. Quality gates
- Unit tests for config parsing, profile resolution, and retries
- Contract tests for MCP tool return schemas
- CI checks (`pytest`, lint, static checks)
148 changes: 148 additions & 0 deletions appium_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
from __future__ import annotations

import json
import os
import re
from pathlib import Path
from typing import Any

import yaml


DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "appium_profiles.yaml"
ENV_CONFIG_PATH = "APPIUM_MCP_CONFIG"

_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")


class AppiumConfigError(ValueError):
"""Raised when Appium MCP configuration is invalid."""


def _load_config_file(config_path: Path) -> dict[str, Any]:
if not config_path.exists():
raise AppiumConfigError(
f"Config file not found: {config_path}. "
"Create one based on appium_profiles.example.yaml."
)

raw = config_path.read_text(encoding="utf-8")

try:
if config_path.suffix.lower() == ".json":
data = json.loads(raw)
else:
data = yaml.safe_load(raw)
except Exception as exc: # parse errors from json/yaml
raise AppiumConfigError(f"Failed to parse config file '{config_path}': {exc}") from exc

if not isinstance(data, dict):
raise AppiumConfigError("Config root must be a JSON/YAML object")

return data


def _get_default_path() -> Path:
configured_path = os.getenv(ENV_CONFIG_PATH)
if configured_path:
return Path(configured_path).expanduser().resolve()
return DEFAULT_CONFIG_PATH


def _resolve_placeholders(value: Any) -> Any:
if isinstance(value, str):
return _ENV_VAR_PATTERN.sub(lambda m: os.getenv(m.group(1), m.group(0)), value)
if isinstance(value, dict):
return {k: _resolve_placeholders(v) for k, v in value.items()}
if isinstance(value, list):
return [_resolve_placeholders(v) for v in value]
return value


def _merge_profile(parent: dict[str, Any], child: dict[str, Any]) -> dict[str, Any]:
merged = dict(parent)
merged.update({k: v for k, v in child.items() if k != "capabilities"})

parent_caps = parent.get("capabilities") if isinstance(parent.get("capabilities"), dict) else {}
child_caps = child.get("capabilities") if isinstance(child.get("capabilities"), dict) else {}
merged["capabilities"] = {**parent_caps, **child_caps}
return merged


def _normalize_profiles(raw_profiles: dict[str, Any]) -> dict[str, dict[str, Any]]:
normalized: dict[str, dict[str, Any]] = {}

def resolve(name: str, stack: set[str]) -> dict[str, Any]:
if name in normalized:
return normalized[name]
if name in stack:
chain = " -> ".join([*stack, name])
raise AppiumConfigError(f"Circular profile inheritance detected: {chain}")

value = raw_profiles.get(name)
if not isinstance(value, dict):
raise AppiumConfigError(f"Profile '{name}' must be an object")

stack.add(name)
parent_name = value.get("extends")

if parent_name is not None and not isinstance(parent_name, str):
raise AppiumConfigError(f"Profile '{name}' field 'extends' must be a string")

if parent_name:
if parent_name not in raw_profiles:
raise AppiumConfigError(
f"Profile '{name}' extends unknown profile '{parent_name}'"
)
parent = resolve(parent_name, stack)
merged = _merge_profile(parent, value)
else:
merged = dict(value)

stack.remove(name)

platform = merged.get("platform")
server_url = merged.get("server_url")
capabilities = merged.get("capabilities")

if not isinstance(platform, str) or not platform:
raise AppiumConfigError(f"Profile '{name}' missing 'platform'")
if not isinstance(server_url, str) or not server_url:
raise AppiumConfigError(f"Profile '{name}' missing 'server_url'")
if not isinstance(capabilities, dict):
raise AppiumConfigError(f"Profile '{name}' missing dict 'capabilities'")

normalized[name] = {
"platform": platform,
"server_url": server_url,
"capabilities": _resolve_placeholders(capabilities),
}
return normalized[name]

for profile_name in raw_profiles:
resolve(profile_name, set())

return normalized


def load_profiles(config_path: str | None = None) -> dict[str, dict[str, Any]]:
path = Path(config_path).expanduser().resolve() if config_path else _get_default_path()
data = _load_config_file(path)
profiles = data.get("profiles")

if not isinstance(profiles, dict) or not profiles:
raise AppiumConfigError("Config must include non-empty 'profiles' mapping")

return _normalize_profiles(profiles)


def get_profile(profile_name: str, config_path: str | None = None) -> dict[str, Any]:
profiles = load_profiles(config_path=config_path)

if profile_name not in profiles:
available = ", ".join(sorted(profiles.keys()))
raise AppiumConfigError(
f"Unknown profile '{profile_name}'. Available profiles: {available}"
)

return profiles[profile_name]
22 changes: 22 additions & 0 deletions appium_profiles.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
profiles:
cloud-base:
platform: iOS
server_url: ${APPIUM_SERVER_URL}
capabilities:
gads:clientSecret: ${GADS_CLIENT_SECRET}

ios-local:
extends: cloud-base
platform: iOS
capabilities:
platformName: iOS
automationName: XCUITest
udid: ${IOS_UDID}

android-local:
platform: Android
server_url: ${APPIUM_SERVER_URL}
capabilities:
platformName: Android
automationName: UiAutomator2
udid: ${ANDROID_UDID}
Loading