feat(mobile-mcp): vendor mobile-mcp with opt-in 0-1000 relative coordinates (#6235)

* Squashed 'packages/mobile-mcp/' content from commit c5d7d27fd

git-subtree-dir: packages/mobile-mcp
git-subtree-split: c5d7d27fd61e4762e15ae4b1c68b6c011be88bb7

* feat(mobile-mcp): vendor mobile-mcp with opt-in 0-1000 relative coordinates

Fork mobile-next/mobile-mcp (v0.0.61) into packages/mobile-mcp/ via git
subtree, renamed to @qwen-code/mobile-mcp with the following additions:

Relative coordinate shim (src/coord-norm.ts):
- MOBILE_MCP_COORDINATE_SPACE=1 enables 0-1000 normalized coordinates
- MOBILE_MCP_COORDINATE_SCALE configurable (default 1000, 999 for mobile_use)
- Input denormalization for click/double_tap/long_press/swipe
- Output normalization for list_elements and get_screen_size
- Tool description rewriting when enabled
- Default off = zero behavior change

Android enhancements:
- mobile_install_app: -r/-g/-d/-t install flags (Android only)
- mobile_ui_dump: full UIAutomator XML hierarchy dump
- mobile_adb_pull / mobile_adb_push: file transfer via ADB

Infrastructure:
- cd-mobile-mcp.yml: npm publish workflow (tag mobile-mcp-v*)
- scripts/sync-from-upstream.sh: git subtree pull for upstream sync
- .vendored-from / .vendored-patches.md: vendoring metadata
- Upstream telemetry disabled by default
- eslint.config.js: exclude packages/mobile-mcp from root lint

* chore(mobile-mcp): update package-lock.json for workspace dependencies

* fix(mobile-mcp): quote all YAML strings to pass yamllint

* fix(mobile-mcp): fix cd workflow yaml to pass both yamllint and actionlint

* fix(mobile-mcp): address review findings on our additions

- ensureScreenSize: log warning instead of silent failure (#4)
- invalidateScreenSize on orientation change (#5)
- adb_push: path.posix.resolve to prevent /sdcard/ traversal (#6)
- adb_pull: readOnlyHint → destructiveHint (writes local file) (#9)
- adb_push: remove validateOutputPath on read-source local_path (#11)
- normalizeElementResult: log error instead of bare catch (#16)
- rewriteDescription: remove dead duplicate regex (#17)
- cd workflow: add test step between build and publish (#19)

* fix(mobile-mcp): update server.json identity and fix package.json main entrypoint

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
顾盼 2026-07-03 16:50:27 +08:00 committed by GitHub
parent b1ec04f4bd
commit 23c6c7032a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 13856 additions and 13 deletions

67
.github/workflows/cd-mobile-mcp.yml vendored Normal file
View file

@ -0,0 +1,67 @@
name: 'CD: mobile-mcp'
on:
push:
tags: ['mobile-mcp-v*']
workflow_dispatch:
inputs:
version:
description: 'Version to publish (without v prefix, e.g. 0.1.0)'
required: true
dry_run:
description: 'Dry run (build only, no publish)'
required: false
type: 'boolean'
default: true
permissions:
contents: 'read'
id-token: 'write'
jobs:
build-and-publish:
runs-on: 'ubuntu-latest'
steps:
- uses: 'actions/checkout@v4'
- uses: 'actions/setup-node@v5'
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: 'Determine version'
id: 'version'
run: |
if [[ "$GITHUB_REF" == refs/tags/mobile-mcp-v* ]]; then
VERSION="${GITHUB_REF#refs/tags/mobile-mcp-v}"
else
VERSION="${{ inputs.version }}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: 'Install dependencies'
working-directory: 'packages/mobile-mcp'
run: 'npm ci --ignore-scripts'
- name: 'Lint'
working-directory: 'packages/mobile-mcp'
run: 'npm run lint'
- name: 'Set version'
working-directory: 'packages/mobile-mcp'
run: 'npm version "${{ steps.version.outputs.version }}" --no-git-tag-version'
- name: 'Build'
working-directory: 'packages/mobile-mcp'
run: 'npm run build'
- name: 'Test'
working-directory: 'packages/mobile-mcp'
run: 'npx playwright test test/coord-norm.test.ts'
- name: 'Publish'
if: '${{ !inputs.dry_run }}'
working-directory: 'packages/mobile-mcp'
run: 'npm publish --access public --provenance'
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'

View file

@ -34,6 +34,7 @@ export default tseslint.config(
'.qwen/**',
'packages/desktop/**',
'packages/cua-driver/**', // vendored trycua/cua driver (Rust + scripts); not qwen-code TS
'packages/mobile-mcp/**', // vendored mobile-next/mobile-mcp; has own eslint config
],
},
eslint.configs.recommended,

1205
package-lock.json generated

File diff suppressed because it is too large Load diff

7
packages/mobile-mcp/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
**/.DS_Store
node_modules
lib
.vscode
coverage
test-results
playwright-report

View file

@ -0,0 +1 @@
c5d7d27fd

View file

@ -0,0 +1,22 @@
# Local patches on top of upstream mobile-next/mobile-mcp
Vendored from: `c5d7d27fd` (upstream v0.0.61, main branch)
Upstream sync mechanism: `git subtree pull` (see `scripts/sync-from-upstream.sh`).
## Local modifications
| Patch | Description | Touches |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Rename to @qwen-code/mobile-mcp | npm package name, repository field | `package.json` |
| Disable upstream telemetry | Fork doesn't phone home to upstream PostHog | `src/server.ts` |
| Relative coordinate shim | Opt-in 01000 normalized coordinates (env `MOBILE_MCP_COORDINATE_SPACE`) | `src/coord-norm.ts`, `src/server.ts` |
| InstallOptions + Android tools | Extended `installApp` with options; added `dumpUiHierarchy`, `pullFile`, `pushFile`; MCP tools `mobile_ui_dump`, `mobile_adb_pull`, `mobile_adb_push` | `src/robot.ts`, `src/android.ts`, `src/server.ts` |
| getAndroidRobotFromDevice helper | DRY extraction for Android-only tool device validation | `src/server.ts` |
## When syncing upstream
After `git subtree pull`, expect conflicts in `src/server.ts` (our coord hooks + new tools)
and `package.json` (name/repo). The coord shim (`src/coord-norm.ts`) is a new file and
won't conflict. Backend files (`android.ts`, `ios.ts`, etc.) are only touched for the
InstallOptions signature change — low conflict surface.

View file

@ -0,0 +1,240 @@
## [0.0.61](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.61) (2026-07-02)
* Fix: Catch system signals for clean v8 exit ([#366](https://github.com/mobile-next/mobile-mcp/pull/366))
* Fix: Detect go-ios installed via npm (bare semver version) ([#355](https://github.com/mobile-next/mobile-mcp/pull/355)), thanks to [@ravijagga](https://github.com/ravijagga)
* Docs: Added CONTRIBUTING.md ([#365](https://github.com/mobile-next/mobile-mcp/pull/365))
* Chore: Updated hono packages for security ([#367](https://github.com/mobile-next/mobile-mcp/pull/367))
## [0.0.60](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.60) (2026-06-15)
* Chore: Updated mobilewright SDK version ([#361](https://github.com/mobile-next/mobile-mcp/pull/361))
## [0.0.59](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.59) (2026-06-09)
* Chore: Updated hono packages for security ([#349](https://github.com/mobile-next/mobile-mcp/pull/349))
* Chore: Updated mobilewright SDK version ([#350](https://github.com/mobile-next/mobile-mcp/pull/350))
## [0.0.58](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.58) (2026-06-02)
* Fix: Understand getRobot device types and platforms ([#346](https://github.com/mobile-next/mobile-mcp/pull/346))
* Chore: Moved tests from mocha/nyc to playwright to reduce dependency vulnerabilities ([#347](https://github.com/mobile-next/mobile-mcp/pull/347))
## [0.0.57](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.57) (2026-05-28)
* Chore: Update mobilewright to 0.0.41 ([#341](https://github.com/mobile-next/mobile-mcp/pull/341))
## [0.0.56](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.56) (2026-05-17)
* Chore: Update mobilewright to 0.0.38 ([#338](https://github.com/mobile-next/mobile-mcp/pull/338))
* Fix: Restore mcp-publisher functionality ([#337](https://github.com/mobile-next/mobile-mcp/pull/337))
## [0.0.55](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.55) (2026-05-16)
* General: Replace mobilecli with mobilewright SDK ([#334](https://github.com/mobile-next/mobile-mcp/pull/334))
* General: Bump fast-xml-parser to 5.8.0 to fix security vulnerability ([#335](https://github.com/mobile-next/mobile-mcp/pull/335))
## [0.0.54](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.54) (2026-05-04)
* Server: Update mobilecli to 0.3.70
* iOS: Fixed cases where testmanagerd would get Device Kit stuck in a black screen of death
* iOS: Added 'placeholder' to view tree response
## [0.0.53](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.53) (2026-05-01)
* Server: Add `mobile_list_crashes` tool to list crash reports on a device
* Server: Add `mobile_get_crash` tool to retrieve full crash report content
* Server: Upgrade mobilecli from 0.2.0 to 0.3.68
* iOS: Replaced use of WebdriverAgent with iOS Device Kit (open source, apache license)
* CI: Restrict `contents` permission to `read`
* CI: Remove Java setup step from build workflow
## [0.0.52](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.52) (2026-04-13)
* Server: Block cross-origin requests on SSE transport ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
* Server: Warn when SSE server starts without `MOBILEMCP_AUTH` set ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
* Server: Reject concurrent SSE connections instead of silently replacing them ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
* Server: Clear SSE transport on connection close to allow reconnection ([#311](https://github.com/mobile-next/mobile-mcp/pull/311))
* Server: Update mobilecli dependency from @mobilenext/mobilecli to mobilecli 0.2.0 ([#316](https://github.com/mobile-next/mobile-mcp/pull/316))
* CI: Fix script injection by passing `github.ref_name` through env vars ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
* CI: Use `npm ci` instead of `npm install` for reproducible builds ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
* CI: Pin mcp-publisher to version 1.5.0 ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
* CI: Remove `npm update` from tag release to preserve lockfile integrity ([#314](https://github.com/mobile-next/mobile-mcp/pull/314))
## [0.0.51](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.51) (2026-04-03)
* Server: Replace `--port` with `--listen` flag accepting `[host:]port` format, default to localhost ([#306](https://github.com/mobile-next/mobile-mcp/pull/306))
* Server: Add optional Bearer token auth via `MOBILEMCP_AUTH` env variable ([#306](https://github.com/mobile-next/mobile-mcp/pull/306))
* Server: Add `MOBILEMCP_DISABLE_TELEMETRY` env variable to opt out of anonymous telemetry ([#305](https://github.com/mobile-next/mobile-mcp/pull/305))
* Server: Security update for path-to-regexp package ([#307](https://github.com/mobile-next/mobile-mcp/pull/307))
## [0.0.50](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.50) (2026-03-27)
* Server: Restrict open_url tool to http/https schemes unless `MOBILEMCP_ALLOW_UNSAFE_URLS=1` is set ([#299](https://github.com/mobile-next/mobile-mcp/pull/299)) thanks to [@manthanghasadiya](https://github.com/manthanghasadiya) for reporting this.
## [0.0.49](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.49) (2026-03-24)
* Server: Fix path traversal in save screenshot and record video ([#296](https://github.com/mobile-next/mobile-mcp/pull/296)) thanks to [@AbhiTheModder](https://github.com/AbhiTheModder) for reporting this.
## [0.0.48](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.48) (2026-03-20)
* Server: Security updates for fast-xml-parser ([#292](https://github.com/mobile-next/mobile-mcp/pull/292))
* Server: Fix handling errors in getDeviceType to prevent empty device list ([#286](https://github.com/mobile-next/mobile-mcp/pull/286)) thanks to [@ls-andy](https://github.com/ls-andy)
## [0.0.47](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.47) (2026-03-09)
* Server: Use zod coerce to fix number parameter parsing ([#284](https://github.com/mobile-next/mobile-mcp/pull/284))
* Server: Updated packages for security ([#285](https://github.com/mobile-next/mobile-mcp/pull/285))
* iOS: Support locales when launching apps ([#283](https://github.com/mobile-next/mobile-mcp/pull/283))
* Android: Support locales when launching apps ([#283](https://github.com/mobile-next/mobile-mcp/pull/283))
## [0.0.46](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.46) (2026-03-03)
* Server: Improved tool description for listing devices, easier on prompting ([#282](https://github.com/mobile-next/mobile-mcp/pull/282))
* iOS: Added support for screen recording for both real devices and simulators ([#282](https://github.com/mobile-next/mobile-mcp/pull/282))
* Android: Added support for screen recording for both real devices and emulators ([#282](https://github.com/mobile-next/mobile-mcp/pull/282))
## [0.0.45](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.45) (2026-03-02)
* Server: Updated fast-xml-parser package for security ([#281](https://github.com/mobile-next/mobile-mcp/pull/281))
* Server: Fix noParams issue that started annoying Claude Code recently ([#280](https://github.com/mobile-next/mobile-mcp/pull/280))
* Android: Fix shell escaping through launchApp ([#279](https://github.com/mobile-next/mobile-mcp/pull/279)) thanks to [@yuhanghuang](https://github.com/yuhanghuang)
* Android: Escape url when calling openUrl ([#278](https://github.com/mobile-next/mobile-mcp/pull/278)) thanks to [yuhanghuang](https://github.com/yuhanghuang)
## [0.0.44](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.44) (2026-02-25)
* General: Rolling out support for remote devices, allocate Android and iOS devices on Mobile Fleet ([#273](https://github.com/mobile-next/mobile-mcp/pull/273))
## [0.0.43](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.43) (2026-02-24)
* General: Increase buffers used for screenshots, fixes bugs where screenshot was >4MB ([#270](https://github.com/mobile-next/mobile-mcp/pull/270))
* General: Upgraded several npm packages for security ([#272](https://github.com/mobile-next/mobile-mcp/pull/272))
## [0.0.42](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.42) (2026-02-03)
* General: Upgraded mobilecli to 0.0.54, [see changes](https://github.com/mobile-next/mobilecli/releases) ([ba3ec1b](https://github.com/mobile-next/mobile-mcp/commit/ba3ec1b9251487ad8444eb22fa3312c7b79d7787))
* General: Updated fast-xml-parser package for security ([#261](https://github.com/mobile-next/mobile-mcp/pull/261))
## [0.0.41](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.41) (2026-01-27)
* General: upgraded mobilecli to 0.0.52, [see changes](https://github.com/mobile-next/mobilecli/releases) ([d7e25f3](https://github.com/mobile-next/mobile-mcp/commit/d7e25f3543e87a436572c29f2b1766bd276a4d68))
* Android: fix: include elements with resource-id or checkable attributes ([#254](https://github.com/mobile-next/mobile-mcp/pull/254)) by [@singhsume123](https://github.com/singhsume123)
## [0.0.40](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.40) (2026-01-15)
* Server: bump @modelcontextprotocol/sdk from 1.24.2 to 1.25.2 for security ([#252](https://github.com/mobile-next/mobile-mcp/pull/252))
## [0.0.39](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.39) (2026-01-01)
* Server: added tool annotations for improved LLM tool understanding ([#246](https://github.com/mobile-next/mobile-mcp/pull/246))
* iOS: added 'duration' parameter to longpress for custom press durations ([#247](https://github.com/mobile-next/mobile-mcp/pull/247))
* Android: added 'duration' parameter to longpress for custom press durations ([#247](https://github.com/mobile-next/mobile-mcp/pull/247))
## [0.0.38](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.38) (2025-12-09)
* iOS: migrated iPhone Simulator calls to use mobilecli binary for improved performance ([#241](https://github.com/mobile-next/mobile-mcp/pull/241))
* iOS: automatically downloading and installing Webdriver Agent on simulator, get started in iOS development in seconds ([#241](https://github.com/mobile-next/mobile-mcp/pull/241))
## [0.0.37](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.37) (2025-12-08)
* Server: migrated to new mcp sdk tool registration API ([#239](https://github.com/mobile-next/mobile-mcp/pull/239))
* Server: updated to @modelcontextprotocol/sdk 1.24.2 and other dependencies for security ([#239](https://github.com/mobile-next/mobile-mcp/pull/239))
## [0.0.36](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.36) (2025-11-19)
* Server: upgraded libraries (glob, js-yaml) and mobilecli ([#234](https://github.com/mobile-next/mobile-mcp/pull/234))
## [0.0.35](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.35) (2025-11-14)
* Server: added list of available MCP tools to README for better discoverability ([043cf3d](https://github.com/mobile-next/mobile-mcp/commit/043cf3d))
* Android: fixed adb path resolution on Windows by always using .exe extension ([178b2fb](https://github.com/mobile-next/mobile-mcp/commit/178b2fb)) by [@mattheww-skyward](https://github.com/mattheww-skyward)
## [0.0.34](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.34) (2025-11-01)
* Server: dry-run release for benchmarking how mobilecli detects devices ([#226](https://github.com/mobile-next/mobile-mcp/pull/226))
## [0.0.33](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.33) (2025-10-20)
* Server: added debug information for understanding screenshot issues on old devices ([#213](https://github.com/mobile-next/mobile-mcp/pull/213))
## [0.0.32](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.32) (2025-10-08)
* Server: fixed wrong separator when listing iOS simulators ([#208](https://github.com/mobile-next/mobile-mcp/pull/208))
* iOS: double tap at screen location ([#207](https://github.com/mobile-next/mobile-mcp/pull/207))
* Android: reduce stdout pollution by adb shell monkey ([#211](https://github.com/mobile-next/mobile-mcp/pull/211))
* Android: fix mobile_take_screenshot on very old android devices ([#204](https://github.com/mobile-next/mobile-mcp/pull/204)) by [@boulaycote](https://github.com/boulaycote)
* Android: double tap at screen location ([#194](https://github.com/mobile-next/mobile-mcp/pull/194)) by [@SakshamSahgal](https://github.com/SakshamSahgal)
## [0.0.31](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.31) (2025-10-07)
* Server: resolve mobilecli libc issues on very old linux distros ([#206](https://github.com/mobile-next/mobile-mcp/pull/206))
* Server: identify mcp-client for compatiblity patches ([#205](https://github.com/mobile-next/mobile-mcp/pull/205))
## [0.0.30](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.30) (2025-10-06)
* Server: introduction of mobilecli tool, will replace imagemagick, sips, go-ios and adb in the future ([#196](https://github.com/mobile-next/mobile-mcp/pull/196))
* iOS: app installation and uninstallation ([#202](https://github.com/mobile-next/mobile-mcp/pull/202))
* Android: app installation and uninstallation ([#202](https://github.com/mobile-next/mobile-mcp/pull/202))
## [0.0.29](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.29) (2025-09-26)
* Server: bumped mcp sdk to latest version ([#199](https://github.com/mobile-next/mobile-mcp/pull/199))
* Server: locked production npm packages to specific version ([#199](https://github.com/mobile-next/mobile-mcp/pull/199))
* Server: renamed tool 'swipe_on_screen' to 'mobile_swipe_on_screen' ([#197](https://github.com/mobile-next/mobile-mcp/pull/197))
## [0.0.28](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.28) (2025-09-15)
* Server: added 'device' parameter to all tools ([#181](https://github.com/mobile-next/mobile-mcp/pull/181))
* Server: enable agents to access multiple devices at once (eg, 'explain what's on screen on all devices connected')
([#181](https://github.com/mobile-next/mobile-mcp/pull/181))
## [0.0.27](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.27) (2025-09-10)
* Server: use 'sips' image scaling on mac if found, removes requirement to install ImageMagick for image scaling ([#188](https://github.com/mobile-next/mobile-mcp/pull/188))
## [0.0.26](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.26) (2025-09-09)
* Server: support listing of mobile-mcp in github's mcp registry ([e96404e](https://github.com/mobile-next/mobile-mcp/commit/e96404e0e513e48ebcfe7956800203cc0f363526))
## [0.0.25](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.25) (2025-09-08)
* Server: install mobile-mcp in vscode with a single-click in README ([#173](https://github.com/mobile-next/mobile-mcp/pull/173))
* Android: try finding 'adb' under $HOME/Library/Android if $ANDROID_HOME is not defined ([#183](https://github.com/mobile-next/mobile-mcp/pull/183))
* Android: better escaping of text input, for improved security ([#182](https://github.com/mobile-next/mobile-mcp/pull/183))
## [0.0.24](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.24) (2025-08-24)
* iOS: new tool for long press ([#143](https://github.com/mobile-next/mobile-mcp/pull/143))
* Android: new tool for long press ([#143](https://github.com/mobile-next/mobile-mcp/pull/143))
* Android: fixed screenshot from devices with multiple devices (foldables) again ([#171](https://github.com/mobile-next/mobile-mcp/pull/171))
## [0.0.23](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.23) (2025-07-31)
* Android: fixed a bug where devices with multiple screens (such as foldables) failed to take and save screenshot ([#159](https://github.com/mobile-next/mobile-mcp/pull/159))
## [0.0.22](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.22) (2025-07-17)
* iOS: fixed detection of go-ios installation ([#132](https://github.com/mobile-next/mobile-mcp/pull/132) by [@codeaholicguy](https://github.com/codeaholicguy)
## [0.0.21](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.21) (2025-06-27)
* Server: use node: prefixed modules (like node:fs) ([449c498](https://github.com/mobile-next/mobile-mcp/commit/449c498e6e9a3e68aab55ea82f15c296171fc05e))
* iOS: automatically start WebDriverAgent on simulator if already installed ([#126](https://github.com/mobile-next/mobile-mcp/pull/126))
* Android: fixed detection of com.mobilenext.devicekit when running mcp on windows ([c11c642](https://github.com/mobile-next/mobile-mcp/commit/c11c6427c71cb7cef6ce87005047df977f6bea8a))
## [0.0.20](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.20) (2025-06-23)
* Server: new tool `save_screenshot` which saves the screenshot to disk, to be used by other mcp servers ([#112](https://github.com/mobile-next/mobile-mcp/pull/112))
* Server: new tool `use_default_device` which picks the only device that is connected, to speed up use ([#112](https://github.com/mobile-next/mobile-mcp/pull/112))
* iOS: Use wda to grab screenshots for both real devices and simulators ([#115](https://github.com/mobile-next/mobile-mcp/pull/115))
* Android: Support for utf-8 text in sendKeys, see [wiki page]() for getting started ([#117](https://github.com/mobile-next/mobile-mcp/pull/117))
## [0.0.19](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.19) (2025-06-16)
* Server: Fixed support for Windsurf, where some tools caused a -32602 error ([#101](https://github.com/mobile-next/mobile-mcp/pull/101)) by [@amebahead](https://github.com/amebahead)
* iOS: Support for swipe left and right. Support x,y,direction,duration for custom swipes ([#92](https://github.com/mobile-next/mobile-mcp/pull/92/)) by [@benlmyers](https://github.com/benlmyers)
* Android: Support for swipe left and right. Support x,y,direction,duration for custom swipes ([#92](https://github.com/mobile-next/mobile-mcp/pull/92/)) by [@benlmyers](https://github.com/benlmyers)
* Android: Fix for get elements on screen, where uiautomator prints out warnings before the actual xml ([#86](https://github.com/mobile-next/mobile-mcp/pull/86)) by [@wenerme](https://github.com/wenerme)
## [0.0.18](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.18) (2025-06-12)
* Server: New support for SSE (Server-Sent-Events) transport, [see wiki for more information](https://github.com/mobile-next/mobile-mcp/wiki/Using-SSE-Transport) ([1b70d40](https://github.com/mobile-next/mobile-mcp/commit/1b70d403cd562a97a0723464f2b286f2fd6eee0a))
* iOS: Using plutil for `simctl listapps` parsing, might probably fix some parsing issues ([cfba3aa](https://github.com/mobile-next/mobile-mcp/commit/cfba3aaac5beb66d08d1138fe42c924309ede303))
* Other: We have a new Slack server, join us at http://mobilenexthq.com/join-slack
## [0.0.17](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.17) (2025-05-16)
* iOS: Fixed parsing of simctl listapps where CFBundleDisplayName contains non-alphanumerical characters ([#59](https://github.com/mobile-next/mobile-mcp/issues/59)) ([bf19771d](https://github.com/mobile-next/mobile-mcp/pull/63/commits/bf19771dcd49444ba4841ec649e3a72a03b54c74))
## [0.0.16](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.16) (2025-05-10)
* Server: Detect if there is a new version of the mcp and notify user ([14b015f](https://github.com/mobile-next/mobile-mcp/commit/14b015f29ab47aa1f3ae122a670a58eb7ef51fd8))
* Server: Instead of returning x,y for tap, return [top,left,width,height] of elements on screen ([3169d2f](https://github.com/mobile-next/mobile-mcp/commit/3169d2f46f0c789e4c3188e137ac645d6f6eb27c))
* iOS: Fixed coordinates location for iOS with retina display after image scaledown ([3169d2f](https://github.com/mobile-next/mobile-mcp/commit/3169d2f46f0c789e4c3188e137ac645d6f6eb27c))
* iOS: Added detection of StaticText and Image in mobile_list_elements_on_screen ([debe75b](https://github.com/mobile-next/mobile-mcp/commit/debe75b5c8afcafcef8328201e9886bffdd1f128))
## [0.0.15](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.15) (2025-05-04)
* Android: Fixed broken Android screenshots on Windows because of crlf ([#53](https://github.com/mobile-next/mobile-mcp/pull/53/files) by [@hanyuan97](https://github.com/hanyuan97))
## [0.0.14](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.14) (2025-05-02)
* Server: Fix a bug where xcrun was required, now works on Linux as well ([7fddba7](https://github.com/mobile-next/mobile-mcp/commit/7fddba71af51690cfa76f81154f72c3120ab7f07))
* Server: Removed dependency on sharp which was causing issues during installation, now ImageMagick is an optional dependency
* Android: Try uiautomator-dump multiple times, in case ui hierarchy is not stable
* Android: Return more information about elements on screen for better element detection
* Android: Support for Android TV using dpad for navigation ([399443d](https://github.com/mobile-next/mobile-mcp/commit/399443d519284a54b670a1598689a73d178db2ec) by [@surajsau](https://github.com/surajsau))
## [0.0.13](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.13) (2025-04-17)
* Server: Fix a bug where 'adb' is required to even work with iOS-only ([#30](https://github.com/mobile-next/mobile-mcp/issues/30)) ([867f662](https://github.com/mobile-next/mobile-mcp/pull/35/commits/867f662ac2edc68d542519bd72d1762d3dbca18d))
* iOS: Support for orientation changes ([844dc0e](https://github.com/mobile-next/mobile-mcp/pull/28/commits/844dc0eb953169871b4cdd2a57735bf50abe721a))
* Android: Support for orientation changes (eg 'change device to landscape') ([844dc0e](https://github.com/mobile-next/mobile-mcp/pull/28/commits/844dc0eb953169871b4cdd2a57735bf50abe721a))
* Android: Improve element detection by using element name if label not found ([8e8aadf](https://github.com/mobile-next/mobile-mcp/pull/33/commits/8e8aadfd7f300ff5b7f0a7857a99d1103cd9e941) by [@tomoya0x00](https://github.com/tomoya0x00))
## [0.0.12](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.12) (2025-04-12)
* Server: If hitting an error with tunnel, forward proxy, wda, descriptive error and link to documentation will be returned
* iOS: go-ios path can be set in env GO_IOS_PATH
* iOS: Support go-ios that was built locally (no version)
* iOS: Return bundle display name for apps for better app launch
* iOS: Fixed finding element coordinates on retina displays
* iOS: Saving temporary screenshots onto temporary directory ([#19](https://github.com/mobile-next/mobile-mcp/issues/19))
* iOS: Find elements better by removing off-screen and hidden elements
* Android: Support for 'adb' under ANDROID_HOME
* Android: Find elements better using accessibility hints and class names
## [0.0.11](https://github.com/mobile-next/mobile-mcp/releases/tag/0.0.11) (2025-04-06)
* Server: Support submit after sending text (\n)
* Server: Added support for multiple devices at the same time
* iOS: Support for iOS physical devices using go-ios ([see wiki](https://github.com/mobile-next/mobile-mcp/wiki/Getting-Started-with-iOS-Physical-Device))
* iOS: Added support for icons, search fields, and switches when getting elements on screen

201
packages/mobile-mcp/LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.

View file

@ -0,0 +1,137 @@
# @qwen-code/mobile-mcp
Fork of [mobile-next/mobile-mcp](https://github.com/mobile-next/mobile-mcp) for [qwen-code](https://github.com/QwenLM/qwen-code), with opt-in relative coordinate support and additional Android tooling.
This package is an MCP server that enables LLM agents to interact with mobile devices (iOS and Android) through screenshots, accessibility elements, and coordinate-based touch actions. It supports simulators, emulators, and real devices.
## Upstream
Based on [mobile-next/mobile-mcp](https://github.com/mobile-next/mobile-mcp) v0.0.61 (`c5d7d27`). We track upstream via `git subtree` and will continue to sync updates. See [.vendored-patches.md](.vendored-patches.md) for local modifications and [scripts/sync-from-upstream.sh](scripts/sync-from-upstream.sh) for the sync mechanism.
## What we added
### 1. Opt-in 0-1000 relative coordinate mode
Mirrors the [cua-driver relative coordinate shim](../cua-driver/docs/relative-coordinates-design.md) for mobile. When enabled, all coordinate inputs/outputs are normalized to a 0-1000 scale, matching the Qwen VL model's `computer_use` / `mobile_use` coordinate convention.
**Environment variables:**
| Variable | Values | Default | Description |
| ----------------------------- | -------------------- | ------- | -------------------------------------------------------- |
| `MOBILE_MCP_COORDINATE_SPACE` | `0` (off) / `1` (on) | `0` | Enable 0-1000 normalized coordinates |
| `MOBILE_MCP_COORDINATE_SCALE` | Any positive integer | `1000` | Full scale value (use `999` for `mobile_use` convention) |
**How it works:**
- **Input denormalization**: Coordinate tools (`mobile_click_on_screen_at_coordinates`, `mobile_double_tap_on_screen`, `mobile_long_press_on_screen_at_coordinates`, `mobile_swipe_on_screen`) convert 0-1000 input to device pixels/points before execution.
- **Output normalization**: `mobile_list_elements_on_screen` element coordinates are converted from pixels/points to 0-1000. `mobile_get_screen_size` reports 1000x1000.
- **Description rewriting**: Tool descriptions change from "in pixels" to "in 0-1000 normalized coordinates" when enabled.
- **Default off**: Zero behavior change when not configured. Fully backward compatible.
The normalization basis is `getScreenSize()` — logical points on iOS, physical pixels on Android. The shim runs entirely in `server.ts`; backend files (`android.ts`, `ios.ts`, etc.) are untouched.
### 2. Extended Android install options
`mobile_install_app` now supports Android-specific flags:
| Parameter | Flag | Description |
| ------------------- | ---- | ------------------------------------- |
| `replace` | `-r` | Replace existing app (default `true`) |
| `grant_permissions` | `-g` | Grant all runtime permissions |
| `allow_downgrade` | `-d` | Allow version code downgrade |
| `allow_test` | `-t` | Allow test APKs |
iOS/simulator silently ignores these options.
### 3. Android-specific tools
| Tool | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mobile_ui_dump` | Dump the full UI hierarchy as raw XML via `uiautomator`. Unlike `mobile_list_elements_on_screen` (filtered flat JSON), this returns the complete unfiltered XML tree with all node attributes. Use for debugging or when `mobile_list_elements_on_screen` misses elements. Supports `--compressed`. |
| `mobile_adb_pull` | Pull a file from Android device to local filesystem |
| `mobile_adb_push` | Push a local file to Android device (default restricted to `/sdcard/`, `force=true` to override) |
### 4. Telemetry disabled by default
Upstream PostHog telemetry is off by default in this fork. Set `MOBILEMCP_ENABLE_TELEMETRY=1` to re-enable.
## Available MCP Tools
### Device Management
- **`mobile_list_available_devices`** - List all available devices (simulators, emulators, and real devices)
- **`mobile_get_screen_size`** - Get the screen size of the mobile device
- **`mobile_get_orientation`** / **`mobile_set_orientation`** - Get/set screen orientation
### App Management
- **`mobile_list_apps`** - List all installed apps
- **`mobile_launch_app`** / **`mobile_terminate_app`** - Launch/terminate apps
- **`mobile_install_app`** - Install app with optional Android flags (-r/-g/-d/-t)
- **`mobile_uninstall_app`** - Uninstall app
### Screen Interaction
- **`mobile_take_screenshot`** / **`mobile_save_screenshot`** - Capture screen
- **`mobile_list_elements_on_screen`** - List UI elements with coordinates (cross-platform)
- **`mobile_click_on_screen_at_coordinates`** - Tap at x,y
- **`mobile_double_tap_on_screen`** - Double-tap at x,y
- **`mobile_long_press_on_screen_at_coordinates`** - Long press at x,y
- **`mobile_swipe_on_screen`** - Swipe in any direction
### Input & Navigation
- **`mobile_type_keys`** - Type text into focused element
- **`mobile_press_button`** - Press device buttons (HOME, BACK, etc.)
- **`mobile_open_url`** - Open URL in browser
### Recording & Debugging
- **`mobile_start_screen_recording`** / **`mobile_stop_screen_recording`** - Screen recording
- **`mobile_list_crashes`** / **`mobile_get_crash`** - Crash reports
### Android Only
- **`mobile_ui_dump`** - Full UI hierarchy XML dump
- **`mobile_adb_pull`** / **`mobile_adb_push`** - File transfer via ADB
## Usage
### MCP server configuration
```json
{
"mcpServers": {
"mobile-mcp": {
"command": "npx",
"args": ["@qwen-code/mobile-mcp"]
}
}
}
```
With relative coordinates enabled:
```json
{
"mcpServers": {
"mobile-mcp": {
"command": "npx",
"args": ["@qwen-code/mobile-mcp"],
"env": {
"MOBILE_MCP_COORDINATE_SPACE": "1"
}
}
}
}
```
### Prerequisites
- **Android**: [Android SDK Platform Tools](https://developer.android.com/tools/releases/platform-tools) (`adb` on PATH)
- **iOS real devices**: [go-ios](https://github.com/danielpaulus/go-ios)
- **iOS simulators**: Xcode with simulator runtimes + [mobilecli](https://github.com/mobile-next/mobilecli) (installed automatically via `mobilewright` dependency)
## License
Apache-2.0 (same as upstream)

View file

@ -0,0 +1,161 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import stylistic from "@stylistic/eslint-plugin";
import importRules from "eslint-plugin-import";
const plugins = {
"@stylistic": stylistic,
"@typescript-eslint": typescriptEslint,
import: importRules,
};
export const baseRules = {
"@typescript-eslint/no-unused-vars": [
2,
{args: "none", caughtErrors: "none"},
],
/**
* Enforced rules
*/
// syntax preferences
"object-curly-spacing": ["error", "always"],
quotes: [
2,
"double",
{
avoidEscape: true,
allowTemplateLiterals: true,
},
],
"jsx-quotes": [2, "prefer-single"],
"no-extra-semi": 2,
"@stylistic/semi": [2],
"comma-style": [2, "last"],
"wrap-iife": [2, "inside"],
"spaced-comment": [
2,
"always",
{
markers: ["*"],
},
],
eqeqeq: [2],
"accessor-pairs": [
2,
{
getWithoutSet: false,
setWithoutGet: false,
},
],
"brace-style": [2, "1tbs", {allowSingleLine: true}],
curly: [2, "all"],
"new-parens": 2,
"arrow-parens": [2, "as-needed"],
"prefer-const": 2,
"quote-props": [2, "consistent"],
"nonblock-statement-body-position": [2, "below"],
// anti-patterns
"no-var": 2,
"no-with": 2,
"no-multi-str": 2,
"no-caller": 2,
"no-implied-eval": 2,
"no-labels": 2,
"no-new-object": 2,
"no-octal-escape": 2,
"no-self-compare": 2,
"no-shadow-restricted-names": 2,
"no-cond-assign": 2,
"no-debugger": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-unreachable": 2,
"no-unsafe-negation": 2,
radix: 2,
"valid-typeof": 2,
"no-implicit-globals": [2],
"no-unused-expressions": [
2,
{allowShortCircuit: true, allowTernary: true, allowTaggedTemplates: true},
],
"no-proto": 2,
// es2015 features
"require-yield": 2,
"template-curly-spacing": [2, "never"],
// spacing details
"space-infix-ops": 2,
"space-in-parens": [2, "never"],
"array-bracket-spacing": [2, "never"],
"comma-spacing": [2, {before: false, after: true}],
"keyword-spacing": [2, "always"],
"space-before-function-paren": [
2,
{
anonymous: "never",
named: "never",
asyncArrow: "always",
},
],
"no-whitespace-before-property": 2,
"keyword-spacing": [
2,
{
overrides: {
if: {after: true},
else: {after: true},
for: {after: true},
while: {after: true},
do: {after: true},
switch: {after: true},
return: {after: true},
},
},
],
"arrow-spacing": [
2,
{
after: true,
before: true,
},
],
"@stylistic/func-call-spacing": 2,
"@stylistic/type-annotation-spacing": 2,
// file whitespace
"no-multiple-empty-lines": [2, {max: 2, maxEOF: 0}],
"no-mixed-spaces-and-tabs": 2,
"no-trailing-spaces": 2,
"linebreak-style": [process.platform === "win32" ? 0 : 2, "unix"],
indent: [
2,
"tab",
{SwitchCase: 1, CallExpression: {arguments: "first"}, MemberExpression: 1},
],
"key-spacing": [
2,
{
beforeColon: false,
},
],
"eol-last": 2,
};
const languageOptions = {
parser: tsParser,
ecmaVersion: 9,
sourceType: "module",
};
export default [
{
files: ["**/*.ts"],
plugins,
languageOptions,
rules: baseRules,
},
];

6261
packages/mobile-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,68 @@
{
"name": "@qwen-code/mobile-mcp",
"version": "0.0.1",
"description": "Mobile MCP with opt-in relative coordinate support",
"repository": {
"type": "git",
"url": "git+https://github.com/QwenLM/qwen-code.git",
"directory": "packages/mobile-mcp"
},
"engines": {
"node": ">=18"
},
"license": "Apache-2.0",
"scripts": {
"build": "tsc && chmod +x lib/index.js",
"lint": "eslint .",
"fixlint": "eslint . --fix",
"test": "c8 playwright test",
"watch": "tsc --watch",
"clean": "rm -rf lib",
"prepare": ""
},
"files": [
"lib"
],
"dependencies": {
"@modelcontextprotocol/sdk": "1.26.0",
"ajv": "^8.18.0",
"commander": "14.0.0",
"express": "5.1.0",
"fast-xml-parser": "5.8.0",
"qs": "^6.15.0",
"zod": "^4.1.13",
"zod-to-json-schema": "3.25.0",
"mobilewright": "0.0.45"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "^1.49.0",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/commander": "^2.12.0",
"@types/express": "^5.0.3",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"c8": "^10.1.3",
"eslint": "^9.19.0",
"eslint-plugin": "^1.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",
"husky": "^9.1.7",
"typescript": "5.8.2"
},
"main": "lib/index.js",
"bin": {
"mcp-server-mobile": "lib/index.js"
},
"directories": {
"lib": "lib"
},
"author": "",
"bugs": {
"url": "https://github.com/QwenLM/qwen-code/issues"
},
"homepage": "https://github.com/QwenLM/qwen-code/tree/main/packages/mobile-mcp#readme"
}

View file

@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test";
// These are plain Node tests (no browser). Playwright is used purely as the
// test runner, so no browser projects are configured.
export default defineConfig({
testDir: "./test",
testMatch: "*.ts",
// Device tests (android/ios/iphone-simulator) mutate real device state and
// must run serially, exactly as they did under mocha's single process.
workers: 1,
fullyParallel: false,
// Device operations include several multi-second sleeps; the 30s default is
// too tight.
timeout: 60_000,
});

View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# sync-from-upstream.sh — pull upstream mobile-mcp changes via git subtree.
#
# Usage:
# packages/mobile-mcp/scripts/sync-from-upstream.sh [<branch-or-tag>]
#
# Default: pulls from 'main' branch of the mobile-mcp-upstream remote.
#
# Prerequisites:
# git remote add mobile-mcp-upstream https://github.com/mobile-next/mobile-mcp.git
#
# After running: review the merge, resolve any conflicts in src/server.ts
# (our coord-norm hooks and new tools), and src/coord-norm.ts (new file,
# won't conflict). Then commit.
#
# If subtree pull becomes impractical (e.g. upstream history grows too large),
# fall back to the cua-driver approach: generate a diff between two refs in a
# local upstream clone and apply with `git apply --reject`.
set -euo pipefail
BRANCH="${1:-main}"
REMOTE="mobile-mcp-upstream"
PREFIX="packages/mobile-mcp"
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
# Ensure remote exists
if ! git remote | grep -q "^${REMOTE}$"; then
echo "Adding remote ${REMOTE}..."
git remote add "$REMOTE" https://github.com/mobile-next/mobile-mcp.git
fi
echo "Fetching ${REMOTE}..."
git fetch "$REMOTE"
echo "Pulling from ${REMOTE}/${BRANCH} into ${PREFIX}..."
git subtree pull --prefix="$PREFIX" "$REMOTE" "$BRANCH" --squash \
-m "chore(mobile-mcp): sync with upstream ${REMOTE}/${BRANCH}"
# Update the vendored-from marker
UPSTREAM_SHA="$(git rev-parse "${REMOTE}/${BRANCH}")"
echo "$UPSTREAM_SHA" > "${PREFIX}/.vendored-from"
git add "${PREFIX}/.vendored-from"
echo ""
echo "Done. Upstream synced to ${UPSTREAM_SHA}."
echo "Review the merge, resolve any conflicts, then amend or commit."

View file

@ -0,0 +1,21 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.qwenlm/mobile-mcp",
"description": "MCP server for iOS and Android Mobile Development, Automation and Testing (qwen-code fork with relative coordinate support)",
"repository": {
"url": "https://github.com/QwenLM/qwen-code",
"source": "github"
},
"version": "{{VERSION}}",
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "@qwen-code/mobile-mcp",
"version": "{{VERSION}}",
"transport": {
"type": "stdio"
}
}
]
}

View file

@ -0,0 +1,857 @@
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import * as xml from 'fast-xml-parser';
import {
ActionableError,
Button,
InstalledApp,
InstallOptions,
Robot,
ScreenElement,
ScreenElementRect,
ScreenSize,
SwipeDirection,
Orientation,
} from './robot';
import { validatePackageName, validateLocale } from './utils';
export interface AndroidDevice {
deviceId: string;
deviceType: 'tv' | 'mobile';
}
interface UiAutomatorXmlNode {
node: UiAutomatorXmlNode[];
class?: string;
text?: string;
bounds?: string;
hint?: string;
focused?: string;
checkable?: string;
'content-desc'?: string;
'resource-id'?: string;
}
interface UiAutomatorXml {
hierarchy: {
node: UiAutomatorXmlNode;
};
}
const getAdbPath = (): string => {
const exeName = process.env.platform === 'win32' ? 'adb.exe' : 'adb';
if (process.env.ANDROID_HOME) {
return path.join(process.env.ANDROID_HOME, 'platform-tools', exeName);
}
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
const windowsAdbPath = path.join(
process.env.LOCALAPPDATA,
'Android',
'Sdk',
'platform-tools',
'adb.exe',
);
if (existsSync(windowsAdbPath)) {
return windowsAdbPath;
}
}
if (process.platform === 'darwin' && process.env.HOME) {
const defaultAndroidSdk = path.join(
process.env.HOME,
'Library',
'Android',
'sdk',
'platform-tools',
'adb',
);
if (existsSync(defaultAndroidSdk)) {
return defaultAndroidSdk;
}
}
// fallthrough, hope for the best
return exeName;
};
const BUTTON_MAP: Record<Button, string> = {
BACK: 'KEYCODE_BACK',
HOME: 'KEYCODE_HOME',
VOLUME_UP: 'KEYCODE_VOLUME_UP',
VOLUME_DOWN: 'KEYCODE_VOLUME_DOWN',
ENTER: 'KEYCODE_ENTER',
DPAD_CENTER: 'KEYCODE_DPAD_CENTER',
DPAD_UP: 'KEYCODE_DPAD_UP',
DPAD_DOWN: 'KEYCODE_DPAD_DOWN',
DPAD_LEFT: 'KEYCODE_DPAD_LEFT',
DPAD_RIGHT: 'KEYCODE_DPAD_RIGHT',
};
const TIMEOUT = 30000;
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
type AndroidDeviceType = 'tv' | 'mobile';
export class AndroidRobot implements Robot {
public constructor(private deviceId: string) {}
public adb(...args: string[]): Buffer {
return execFileSync(getAdbPath(), ['-s', this.deviceId, ...args], {
maxBuffer: MAX_BUFFER_SIZE,
timeout: TIMEOUT,
});
}
public silentAdb(...args: string[]): Buffer {
return execFileSync(getAdbPath(), ['-s', this.deviceId, ...args], {
maxBuffer: MAX_BUFFER_SIZE,
timeout: TIMEOUT,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
public getSystemFeatures(): string[] {
return this.adb('shell', 'pm', 'list', 'features')
.toString()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('feature:'))
.map((line) => line.substring('feature:'.length));
}
public async getScreenSize(): Promise<ScreenSize> {
const screenSize = this.adb('shell', 'wm', 'size')
.toString()
.split(' ')
.pop();
if (!screenSize) {
throw new Error('Failed to get screen size');
}
const scale = 1;
const [width, height] = screenSize.split('x').map(Number);
return { width, height, scale };
}
public async listApps(): Promise<InstalledApp[]> {
// only apps that have a launcher activity are returned
return this.adb(
'shell',
'cmd',
'package',
'query-activities',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.LAUNCHER',
)
.toString()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('packageName='))
.map((line) => line.substring('packageName='.length))
.filter((value, index, self) => self.indexOf(value) === index)
.map((packageName) => ({
packageName,
appName: packageName,
}));
}
private async listPackages(): Promise<string[]> {
return this.adb('shell', 'pm', 'list', 'packages')
.toString()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('package:'))
.map((line) => line.substring('package:'.length));
}
public async launchApp(packageName: string, locale?: string): Promise<void> {
validatePackageName(packageName);
if (locale) {
validateLocale(locale);
try {
this.silentAdb(
'shell',
'cmd',
'locale',
'set-app-locales',
packageName,
'--locales',
locale,
);
} catch (error) {
// set-app-locales requires Android 13+ (API 33), silently ignore on older versions
}
}
try {
this.silentAdb(
'shell',
'monkey',
'-p',
packageName,
'-c',
'android.intent.category.LAUNCHER',
'1',
);
} catch (error) {
throw new ActionableError(
`Failed launching app with package name "${packageName}", please make sure it exists`,
);
}
}
public async listRunningProcesses(): Promise<string[]> {
return this.adb('shell', 'ps', '-e')
.toString()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('u')) // non-system processes
.map((line) => line.split(/\s+/)[8]); // get process name
}
public async swipe(direction: SwipeDirection): Promise<void> {
const screenSize = await this.getScreenSize();
const centerX = screenSize.width >> 1;
let x0: number, y0: number, x1: number, y1: number;
switch (direction) {
case 'up':
x0 = x1 = centerX;
y0 = Math.floor(screenSize.height * 0.8);
y1 = Math.floor(screenSize.height * 0.2);
break;
case 'down':
x0 = x1 = centerX;
y0 = Math.floor(screenSize.height * 0.2);
y1 = Math.floor(screenSize.height * 0.8);
break;
case 'left':
x0 = Math.floor(screenSize.width * 0.8);
x1 = Math.floor(screenSize.width * 0.2);
y0 = y1 = Math.floor(screenSize.height * 0.5);
break;
case 'right':
x0 = Math.floor(screenSize.width * 0.2);
x1 = Math.floor(screenSize.width * 0.8);
y0 = y1 = Math.floor(screenSize.height * 0.5);
break;
default:
throw new ActionableError(
`Swipe direction "${direction}" is not supported`,
);
}
this.adb(
'shell',
'input',
'swipe',
`${x0}`,
`${y0}`,
`${x1}`,
`${y1}`,
'1000',
);
}
public async swipeFromCoordinate(
x: number,
y: number,
direction: SwipeDirection,
distance?: number,
): Promise<void> {
const screenSize = await this.getScreenSize();
let x0: number, y0: number, x1: number, y1: number;
// Use provided distance or default to 30% of screen dimension
const defaultDistanceY = Math.floor(screenSize.height * 0.3);
const defaultDistanceX = Math.floor(screenSize.width * 0.3);
const swipeDistanceY = distance || defaultDistanceY;
const swipeDistanceX = distance || defaultDistanceX;
switch (direction) {
case 'up':
x0 = x1 = x;
y0 = y;
y1 = Math.max(0, y - swipeDistanceY);
break;
case 'down':
x0 = x1 = x;
y0 = y;
y1 = Math.min(screenSize.height, y + swipeDistanceY);
break;
case 'left':
x0 = x;
x1 = Math.max(0, x - swipeDistanceX);
y0 = y1 = y;
break;
case 'right':
x0 = x;
x1 = Math.min(screenSize.width, x + swipeDistanceX);
y0 = y1 = y;
break;
default:
throw new ActionableError(
`Swipe direction "${direction}" is not supported`,
);
}
this.adb(
'shell',
'input',
'swipe',
`${x0}`,
`${y0}`,
`${x1}`,
`${y1}`,
'1000',
);
}
private getDisplayCount(): number {
return this.adb('shell', 'dumpsys', 'SurfaceFlinger', '--display-id')
.toString()
.split('\n')
.filter((s) => s.startsWith('Display ')).length;
}
private getFirstDisplayId(): string | null {
try {
// Try using cmd display get-displays (Android 11+)
const displays = this.adb('shell', 'cmd', 'display', 'get-displays')
.toString()
.split('\n')
.filter((s) => s.startsWith('Display id '))
// filter for state ON even though get-displays only returns turned on displays
.filter((s) => s.indexOf(', state ON,') >= 0)
// another paranoia check
.filter((s) => s.indexOf(', uniqueId ') >= 0);
if (displays.length > 0) {
const m = displays[0].match(/uniqueId \"([^\"]+)\"/);
if (m !== null) {
let displayId = m[1];
if (displayId.startsWith('local:')) {
displayId = displayId.substring('local:'.length);
}
return displayId;
}
}
} catch (error) {
// cmd display get-displays not available on this device
}
// fallback: parse dumpsys display for display info (compatible with older Android versions)
try {
const dumpsys = this.adb('shell', 'dumpsys', 'display').toString();
// look for DisplayViewport entries with isActive=true and type=INTERNAL
const viewportMatch = dumpsys.match(
/DisplayViewport\{type=INTERNAL[^}]*isActive=true[^}]*uniqueId='([^']+)'/,
);
if (viewportMatch) {
let uniqueId = viewportMatch[1];
if (uniqueId.startsWith('local:')) {
uniqueId = uniqueId.substring('local:'.length);
}
return uniqueId;
}
// fallback: look for active display with state ON
const displayStateMatch = dumpsys.match(
/Display Id=(\d+)[\s\S]*?Display State=ON/,
);
if (displayStateMatch) {
return displayStateMatch[1];
}
} catch (error) {
// dumpsys display also failed
}
return null;
}
public async getScreenshot(): Promise<Buffer> {
if (this.getDisplayCount() <= 1) {
// backward compatibility for android 10 and below, and for single display devices
return this.adb('exec-out', 'screencap', '-p');
}
// find the first display that is turned on, and capture that one
const displayId = this.getFirstDisplayId();
if (displayId === null) {
// no idea why, but we have displayCount >= 2, yet we failed to parse
// let's go with screencap's defaults and hope for the best
return this.adb('exec-out', 'screencap', '-p');
}
return this.adb('exec-out', 'screencap', '-p', '-d', `${displayId}`);
}
private collectElements(node: UiAutomatorXmlNode): ScreenElement[] {
const elements: Array<ScreenElement> = [];
if (node.node) {
if (Array.isArray(node.node)) {
for (const childNode of node.node) {
elements.push(...this.collectElements(childNode));
}
} else {
elements.push(...this.collectElements(node.node));
}
}
if (
node.text ||
node['content-desc'] ||
node.hint ||
node['resource-id'] ||
node.checkable === 'true'
) {
const element: ScreenElement = {
type: node.class || 'text',
text: node.text,
label: node['content-desc'] || node.hint || '',
rect: this.getScreenElementRect(node),
};
if (node.focused === 'true') {
// only provide it if it's true, otherwise don't confuse llm
element.focused = true;
}
const resourceId = node['resource-id'];
if (resourceId !== null && resourceId !== '') {
element.identifier = resourceId;
}
if (element.rect.width > 0 && element.rect.height > 0) {
elements.push(element);
}
}
return elements;
}
public async getElementsOnScreen(): Promise<ScreenElement[]> {
const parsedXml = await this.getUiAutomatorXml();
const hierarchy = parsedXml.hierarchy;
const elements = this.collectElements(hierarchy.node);
return elements;
}
public async terminateApp(packageName: string): Promise<void> {
validatePackageName(packageName);
this.adb('shell', 'am', 'force-stop', packageName);
}
public async installApp(
path: string,
options?: InstallOptions,
): Promise<void> {
try {
const args: string[] = ['install'];
const opts = options || {};
if (opts.replace !== false) {
args.push('-r');
}
if (opts.grantPermissions) {
args.push('-g');
}
if (opts.allowDowngrade) {
args.push('-d');
}
if (opts.allowTest) {
args.push('-t');
}
args.push(path);
this.adb(...args);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
}
}
public async uninstallApp(bundleId: string): Promise<void> {
try {
this.adb('uninstall', bundleId);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
}
}
public async openUrl(url: string): Promise<void> {
this.adb(
'shell',
'am',
'start',
'-a',
'android.intent.action.VIEW',
'-d',
this.escapeShellText(url),
);
}
private isAscii(text: string): boolean {
return /^[\x00-\x7F]*$/.test(text);
}
private escapeShellText(text: string): string {
// escape all shell special characters that could be used for injection
return text.replace(/[\\'"` \t\n\r|&;()<>{}[\]$*?]/g, '\\$&');
}
private async isDeviceKitInstalled(): Promise<boolean> {
const packages = await this.listPackages();
return packages.includes('com.mobilenext.devicekit');
}
public async sendKeys(text: string): Promise<void> {
if (text === '') {
// bailing early, so we don't run adb shell with empty string.
// this happens when you prompt with a simple "submit".
return;
}
if (this.isAscii(text)) {
// adb shell input only supports ascii characters. and
// some of the keys have to be escaped.
const _text = this.escapeShellText(text);
this.adb('shell', 'input', 'text', _text);
} else if (await this.isDeviceKitInstalled()) {
// try sending over clipboard
const base64 = Buffer.from(text).toString('base64');
// send clipboard over and immediately paste it
this.adb(
'shell',
'am',
'broadcast',
'-a',
'devicekit.clipboard.set',
'-e',
'encoding',
'base64',
'-e',
'text',
base64,
'-n',
'com.mobilenext.devicekit/.ClipboardBroadcastReceiver',
);
this.adb('shell', 'input', 'keyevent', 'KEYCODE_PASTE');
// clear clipboard when we're done
this.adb(
'shell',
'am',
'broadcast',
'-a',
'devicekit.clipboard.clear',
'-n',
'com.mobilenext.devicekit/.ClipboardBroadcastReceiver',
);
} else {
throw new ActionableError(
'Non-ASCII text is not supported on Android, please install mobilenext devicekit, see https://github.com/mobile-next/devicekit-android',
);
}
}
public async pressButton(button: Button) {
if (!BUTTON_MAP[button]) {
throw new ActionableError(`Button "${button}" is not supported`);
}
const mapped = BUTTON_MAP[button];
this.adb('shell', 'input', 'keyevent', mapped);
}
public async tap(x: number, y: number): Promise<void> {
this.adb('shell', 'input', 'tap', `${x}`, `${y}`);
}
public async longPress(
x: number,
y: number,
duration: number,
): Promise<void> {
// a long press is a swipe with no movement and a long duration
this.adb(
'shell',
'input',
'swipe',
`${x}`,
`${y}`,
`${x}`,
`${y}`,
`${duration}`,
);
}
public async doubleTap(x: number, y: number): Promise<void> {
await this.tap(x, y);
await new Promise((r) => setTimeout(r, 100)); // short delay
await this.tap(x, y);
}
public async setOrientation(orientation: Orientation): Promise<void> {
const value = orientation === 'portrait' ? 0 : 1;
// disable auto-rotation prior to setting the orientation
this.adb(
'shell',
'settings',
'put',
'system',
'accelerometer_rotation',
'0',
);
this.adb(
'shell',
'content',
'insert',
'--uri',
'content://settings/system',
'--bind',
'name:s:user_rotation',
'--bind',
`value:i:${value}`,
);
}
public async getOrientation(): Promise<Orientation> {
const rotation = this.adb(
'shell',
'settings',
'get',
'system',
'user_rotation',
)
.toString()
.trim();
return rotation === '0' ? 'portrait' : 'landscape';
}
public async dumpUiHierarchy(compressed: boolean = false): Promise<string> {
for (let tries = 0; tries < 10; tries++) {
const args = ['exec-out', 'uiautomator', 'dump'];
if (compressed) {
args.push('--compressed');
}
args.push('/dev/tty');
const dump = this.adb(...args).toString();
if (dump.includes('null root node returned by UiTestAutomationBridge')) {
continue;
}
const xmlStart = dump.indexOf('<?xml');
if (xmlStart === -1) {
continue;
}
return dump.substring(xmlStart);
}
throw new ActionableError('Failed to get UIAutomator XML dump');
}
public pullFile(remotePath: string, localPath: string): void {
try {
this.adb('pull', remotePath, localPath);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
}
}
public pushFile(localPath: string, remotePath: string): void {
try {
this.adb('push', localPath, remotePath);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
}
}
private async getUiAutomatorDump(): Promise<string> {
for (let tries = 0; tries < 10; tries++) {
const dump = this.adb(
'exec-out',
'uiautomator',
'dump',
'/dev/tty',
).toString();
// note: we're not catching other errors here. maybe we should check for <?xml
if (dump.includes('null root node returned by UiTestAutomationBridge')) {
// uncomment for debugging
// const screenshot = await this.getScreenshot();
// console.error("Failed to get UIAutomator XML. Here's a screenshot: " + screenshot.toString("base64"));
continue;
}
return dump.substring(dump.indexOf('<?xml'));
}
throw new ActionableError('Failed to get UIAutomator XML');
}
private async getUiAutomatorXml(): Promise<UiAutomatorXml> {
const dump = await this.getUiAutomatorDump();
const parser = new xml.XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
});
return parser.parse(dump) as UiAutomatorXml;
}
private getScreenElementRect(node: UiAutomatorXmlNode): ScreenElementRect {
const bounds = String(node.bounds);
const [, left, top, right, bottom] =
bounds.match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/)?.map(Number) || [];
return {
x: left,
y: top,
width: right - left,
height: bottom - top,
};
}
}
export class AndroidDeviceManager {
private getDeviceType(name: string): AndroidDeviceType {
try {
const device = new AndroidRobot(name);
const features = device.getSystemFeatures();
if (
features.includes('android.software.leanback') ||
features.includes('android.hardware.type.television')
) {
return 'tv';
}
return 'mobile';
} catch (error) {
// Fallback to mobile if we cannot determine device type
return 'mobile';
}
}
private getDeviceVersion(deviceId: string): string {
try {
const output = execFileSync(
getAdbPath(),
['-s', deviceId, 'shell', 'getprop', 'ro.build.version.release'],
{
timeout: 5000,
},
)
.toString()
.trim();
return output;
} catch (error) {
return 'unknown';
}
}
private getDeviceName(deviceId: string): string {
try {
// Try getting AVD name first (for emulators)
const avdName = execFileSync(
getAdbPath(),
['-s', deviceId, 'shell', 'getprop', 'ro.boot.qemu.avd_name'],
{
timeout: 5000,
},
)
.toString()
.trim();
if (avdName !== '') {
// Replace underscores with spaces (e.g., "Pixel_9_Pro" -> "Pixel 9 Pro")
return avdName.replace(/_/g, ' ');
}
// Fall back to product model
const output = execFileSync(
getAdbPath(),
['-s', deviceId, 'shell', 'getprop', 'ro.product.model'],
{
timeout: 5000,
},
)
.toString()
.trim();
return output;
} catch (error) {
return deviceId;
}
}
public getConnectedDevices(): AndroidDevice[] {
try {
const names = execFileSync(getAdbPath(), ['devices'])
.toString()
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '')
.filter((line) => !line.startsWith('List of devices attached'))
.filter((line) => line.split('\t')[1]?.trim() === 'device') // Only include devices that are online and ready
.map((line) => line.split('\t')[0]);
return names.map((name) => ({
deviceId: name,
deviceType: this.getDeviceType(name),
}));
} catch (error) {
console.error(
'Could not execute adb command, maybe ANDROID_HOME is not set?',
);
return [];
}
}
public getConnectedDevicesWithDetails(): Array<
AndroidDevice & { version: string; name: string }
> {
try {
const names = execFileSync(getAdbPath(), ['devices'])
.toString()
.split('\n')
.map((line) => line.trim())
.filter((line) => line !== '')
.filter((line) => !line.startsWith('List of devices attached'))
.filter((line) => line.split('\t')[1]?.trim() === 'device') // Only include devices that are online and ready
.map((line) => line.split('\t')[0]);
return names.map((deviceId) => ({
deviceId,
deviceType: this.getDeviceType(deviceId),
version: this.getDeviceVersion(deviceId),
name: this.getDeviceName(deviceId),
}));
} catch (error) {
console.error(
'Could not execute adb command, maybe ANDROID_HOME is not set?',
);
return [];
}
}
}

View file

@ -0,0 +1,186 @@
// coord-norm.ts — Opt-in 01000 relative-coordinate shim for mobile-mcp.
//
// Mirrors packages/cua-driver's coord_norm.rs design. Default off = pixel
// passthrough (zero behavior change). When on, input coordinates are
// denormalized from 0scale to device pixels/points before reaching the
// backend, and output coordinates are normalized back.
//
// Env vars:
// MOBILE_MCP_COORDINATE_SPACE "0" (default, off) | "1" (on)
// MOBILE_MCP_COORDINATE_SCALE default 1000
// ── Global config (read once at module load) ─────────────────────────────────
export function isNormalized(): boolean {
return process.env.MOBILE_MCP_COORDINATE_SPACE === '1';
}
export function coordinateScale(): number {
const raw = parseInt(process.env.MOBILE_MCP_COORDINATE_SCALE || '1000', 10);
return isNaN(raw) || raw <= 0 ? 1000 : raw;
}
// ── Scalar conversion ────────────────────────────────────────────────────────
export function normToPx(norm: number, dim: number, scale: number): number {
return Math.round((norm / scale) * dim);
}
export function pxToNorm(px: number, dim: number, scale: number): number {
if (dim === 0) return 0;
return Math.round((px / dim) * scale);
}
// ── Per-tool coordinate field mapping ────────────────────────────────────────
interface CoordField {
field: string;
isX: boolean; // true = scale by width, false = scale by height
}
const INPUT_COORD_FIELDS: Record<string, CoordField[]> = {
mobile_click_on_screen_at_coordinates: [
{ field: 'x', isX: true },
{ field: 'y', isX: false },
],
mobile_double_tap_on_screen: [
{ field: 'x', isX: true },
{ field: 'y', isX: false },
],
mobile_long_press_on_screen_at_coordinates: [
{ field: 'x', isX: true },
{ field: 'y', isX: false },
],
mobile_swipe_on_screen: [
{ field: 'x', isX: true },
{ field: 'y', isX: false },
],
};
// ── Per-device screen size cache ─────────────────────────────────────────────
const screenSizeCache = new Map<string, { width: number; height: number }>();
export function cacheScreenSize(
deviceId: string,
width: number,
height: number,
): void {
screenSizeCache.set(deviceId, { width, height });
}
export function invalidateScreenSize(deviceId: string): void {
screenSizeCache.delete(deviceId);
}
export function getCachedScreenSize(
deviceId: string,
): { width: number; height: number } | undefined {
return screenSizeCache.get(deviceId);
}
// ── Input: denormalize 0scale → pixels/points ──────────────────────────────
export function denormalizeArgs(
toolName: string,
args: Record<string, any>,
screenWidth: number,
screenHeight: number,
): void {
const scale = coordinateScale();
const fields = INPUT_COORD_FIELDS[toolName];
if (!fields) return;
for (const { field, isX } of fields) {
if (typeof args[field] === 'number') {
const dim = isX ? screenWidth : screenHeight;
args[field] = normToPx(args[field], dim, scale);
}
}
// swipe distance: scale by the axis matching the swipe direction
if (
toolName === 'mobile_swipe_on_screen' &&
typeof args.distance === 'number'
) {
const dir = args.direction;
const dim = dir === 'up' || dir === 'down' ? screenHeight : screenWidth;
args.distance = normToPx(args.distance, dim, scale);
}
}
// ── Output: normalize element coordinates and screen size ────────────────────
export function normalizeElementResult(
toolName: string,
response: string,
screenWidth: number,
screenHeight: number,
): string {
if (toolName !== 'mobile_list_elements_on_screen') return response;
const scale = coordinateScale();
const prefix = 'Found these elements on screen: ';
if (!response.startsWith(prefix)) return response;
try {
const elements = JSON.parse(response.substring(prefix.length));
for (const el of elements) {
if (el.coordinates) {
el.coordinates.x = pxToNorm(el.coordinates.x, screenWidth, scale);
el.coordinates.y = pxToNorm(el.coordinates.y, screenHeight, scale);
el.coordinates.width = pxToNorm(
el.coordinates.width,
screenWidth,
scale,
);
el.coordinates.height = pxToNorm(
el.coordinates.height,
screenHeight,
scale,
);
}
}
return prefix + JSON.stringify(elements);
} catch (err) {
console.error('[coord-norm] Failed to normalize element coordinates:', err);
return response;
}
}
export function normalizeScreenSizeResult(
toolName: string,
response: string,
): string {
if (toolName !== 'mobile_get_screen_size') return response;
const scale = coordinateScale();
return `Screen size is ${scale}x${scale} (normalized 0-${scale} coordinate space)`;
}
// ── Ingest: extract screen size from get_screen_size response ────────────────
export function ingestScreenSizeFromResult(
deviceId: string,
response: string,
): void {
// Response format: "Screen size is WIDTHxHEIGHT pixels"
const match = response.match(/Screen size is (\d+)x(\d+)/);
if (match) {
cacheScreenSize(deviceId, parseInt(match[1], 10), parseInt(match[2], 10));
}
}
// ── Description rewriting ────────────────────────────────────────────────────
export function rewriteDescription(description: string): string {
const scale = coordinateScale();
return description.replace(
/\bin pixels\b/g,
`in 0-${scale} normalized coordinates`,
);
}
export function coordParamDesc(baseDesc: string): string {
if (!isNormalized()) return baseDesc;
return rewriteDescription(baseDesc);
}

View file

@ -0,0 +1,164 @@
import { execFileSync, spawnSync } from "child_process";
import os from "node:os";
import fs from "node:fs";
import path from "node:path";
import { trace } from "./logger";
const DEFAULT_JPEG_QUALITY = 75;
export class ImageTransformer {
private newWidth: number = 0;
private newFormat: "jpg" | "png" = "png";
private jpegOptions: { quality: number } = { quality: DEFAULT_JPEG_QUALITY };
constructor(private buffer: Buffer) {}
public resize(width: number): ImageTransformer {
this.newWidth = width;
return this;
}
public jpeg(options: { quality: number }): ImageTransformer {
this.newFormat = "jpg";
this.jpegOptions = options;
return this;
}
public png(): ImageTransformer {
this.newFormat = "png";
return this;
}
public toBuffer(): Buffer {
if (isSipsInstalled()) {
try {
return this.toBufferWithSips();
} catch (error) {
trace(`Sips failed, falling back to ImageMagick: ${error}`);
}
}
try {
return this.toBufferWithImageMagick();
} catch (error) {
trace(`ImageMagick failed: ${error}`);
throw new Error("Image scaling unavailable (requires Sips or ImageMagick).");
}
}
private qualityToSips(q: number): "low" | "normal" | "high" | "best" {
if (q >= 90) {
return "best";
}
if (q >= 75) {
return "high";
}
if (q >= 50) {
return "normal";
}
return "low";
}
private toBufferWithSips(): Buffer {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "image-"));
const inputFile = path.join(tempDir, "input");
const outputFile = path.join(tempDir, `output.${this.newFormat === "jpg" ? "jpg" : "png"}`);
try {
fs.writeFileSync(inputFile, this.buffer);
const args = ["-s", "format", this.newFormat === "jpg" ? "jpeg" : "png"];
if (this.newFormat === "jpg") {
args.push("-s", "formatOptions", this.qualityToSips(this.jpegOptions.quality));
}
args.push("-Z", `${this.newWidth}`);
args.push("--out", outputFile);
args.push(inputFile);
trace(`Running sips command: /usr/bin/sips ${args.join(" ")}`);
const proc = spawnSync("/usr/bin/sips", args, {
maxBuffer: 8 * 1024 * 1024
});
if (proc.status !== 0) {
throw new Error(`Sips failed with status ${proc.status}`);
}
const outputBuffer = fs.readFileSync(outputFile);
trace("Sips returned buffer of size: " + outputBuffer.length);
return outputBuffer;
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
}
}
private toBufferWithImageMagick(): Buffer {
const magickArgs = ["-", "-resize", `${this.newWidth}x`, "-quality", `${this.jpegOptions.quality}`, `${this.newFormat}:-`];
trace(`Running magick command: magick ${magickArgs.join(" ")}`);
const proc = spawnSync("magick", magickArgs, {
maxBuffer: 8 * 1024 * 1024,
input: this.buffer
});
return proc.stdout;
}
}
export class Image {
constructor(private buffer: Buffer) {}
public static fromBuffer(buffer: Buffer): Image {
return new Image(buffer);
}
public resize(width: number): ImageTransformer {
return new ImageTransformer(this.buffer).resize(width);
}
public jpeg(options: { quality: number }): ImageTransformer {
return new ImageTransformer(this.buffer).jpeg(options);
}
}
const isDarwin = (): boolean => {
return os.platform() === "darwin";
};
export const isSipsInstalled = (): boolean => {
if (!isDarwin()) {
return false;
}
try {
execFileSync("/usr/bin/sips", ["--version"]);
return true;
} catch (error) {
return false;
}
};
export const isImageMagickInstalled = (): boolean => {
try {
return execFileSync("magick", ["--version"])
.toString()
.split("\n")
.filter(line => line.includes("Version: ImageMagick"))
.length > 0;
} catch (error) {
return false;
}
};
export const isScalingAvailable = (): boolean => {
return isImageMagickInstalled() || isSipsInstalled();
};

View file

@ -0,0 +1,132 @@
#!/usr/bin/env node
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpServer, getAgentVersion } from "./server";
import { error } from "./logger";
import express from "express";
import { program } from "commander";
const startSseServer = async (host: string, port: number) => {
const app = express();
const server = createMcpServer();
const authToken = process.env.MOBILEMCP_AUTH;
if (!authToken) {
error("WARNING: MOBILEMCP_AUTH is not set. The SSE server will accept unauthenticated connections. Set MOBILEMCP_AUTH to require Bearer token authentication.");
}
if (authToken) {
app.use((req, res, next) => {
if (req.headers.authorization !== `Bearer ${authToken}`) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
});
}
// Block cross-origin requests — MCP clients are not browsers
app.use((req, res, next) => {
if (req.headers.origin) {
res.status(403).json({ error: "Cross-origin requests are not allowed" });
return;
}
if (req.method === "OPTIONS") {
res.status(403).end();
return;
}
next();
});
let transport: SSEServerTransport | null = null;
app.post("/mcp", (req, res) => {
if (transport) {
transport.handlePostMessage(req, res);
}
});
app.get("/mcp", (req, res) => {
if (transport) {
res.status(409).json({ error: "Another client is already connected. Disconnect the existing client first." });
return;
}
transport = new SSEServerTransport("/mcp", res);
transport.onclose = () => {
transport = null;
};
server.connect(transport);
});
app.listen(port, host, () => {
error(`mobile-mcp ${getAgentVersion()} sse server listening on http://${host}:${port}/mcp`);
});
};
const startStdioServer = async () => {
try {
const transport = new StdioServerTransport();
const server = createMcpServer();
await server.connect(transport);
// Exit cleanly on termination signals so node flushes pending work
// (including NODE_V8_COVERAGE output). Node's default SIGINT/SIGTERM
// handling terminates the process without writing the coverage file,
// which makes the `test:mcp` report come back all zeros.
const shutdown = () => {
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
error("mobile-mcp server running on stdio");
} catch (err: any) {
console.error("Fatal error in main():", err);
error("Fatal error in main(): " + JSON.stringify(err.stack));
process.exit(1);
}
};
const main = async () => {
program
.version(getAgentVersion())
.option("--listen <listen>", "Start SSE server on [host:]port")
.option("--stdio", "Start stdio server (default)")
.parse(process.argv);
const options = program.opts();
if (options.listen) {
const listen = (options.listen as string).trim();
const lastColon = listen.lastIndexOf(":");
let host = "localhost";
let rawPort: string;
if (lastColon > 0) {
host = listen.substring(0, lastColon);
rawPort = listen.substring(lastColon + 1);
} else {
rawPort = listen;
}
const port = Number.parseInt(rawPort, 10);
if (!host || !rawPort || !Number.isInteger(port) || port < 1 || port > 65535) {
error(`Invalid --listen value "${listen}". Expected [host:]port with port 1-65535.`);
process.exit(1);
}
await startSseServer(host, port);
} else {
await startStdioServer();
}
};
main().then();

View file

@ -0,0 +1,348 @@
import { Socket } from 'node:net';
import { execFileSync } from 'node:child_process';
import { WebDriverAgent } from './webdriver-agent';
import {
ActionableError,
Button,
InstalledApp,
InstallOptions,
Robot,
ScreenSize,
SwipeDirection,
ScreenElement,
Orientation,
} from './robot';
import { validatePackageName, validateLocale } from './utils';
const WDA_PORT = 8100;
const IOS_TUNNEL_PORT = 60105;
interface ListCommandOutput {
deviceList: string[];
}
interface VersionCommandOutput {
version: string;
}
interface InfoCommandOutput {
DeviceClass: string;
DeviceName: string;
ProductName: string;
ProductType: string;
ProductVersion: string;
PhoneNumber: string;
TimeZone: string;
}
export interface IosDevice {
deviceId: string;
deviceName: string;
}
const getGoIosPath = (): string => {
if (process.env.GO_IOS_PATH) {
return process.env.GO_IOS_PATH;
}
// fallback to go-ios in PATH via `npm install -g go-ios`
return 'ios';
};
export class IosRobot implements Robot {
public constructor(private deviceId: string) {}
private isListeningOnPort(port: number): Promise<boolean> {
return new Promise((resolve, reject) => {
const client = new Socket();
client.connect(port, 'localhost', () => {
client.destroy();
resolve(true);
});
client.on('error', (err: any) => {
resolve(false);
});
});
}
private async isTunnelRunning(): Promise<boolean> {
return await this.isListeningOnPort(IOS_TUNNEL_PORT);
}
private async isWdaForwardRunning(): Promise<boolean> {
return await this.isListeningOnPort(WDA_PORT);
}
private async assertTunnelRunning(): Promise<void> {
if (await this.isTunnelRequired()) {
if (!(await this.isTunnelRunning())) {
throw new ActionableError(
'iOS tunnel is not running, please see https://github.com/mobile-next/mobile-mcp/wiki/',
);
}
}
}
private async wda(): Promise<WebDriverAgent> {
await this.assertTunnelRunning();
if (!(await this.isWdaForwardRunning())) {
throw new ActionableError(
'Port forwarding to WebDriverAgent is not running (tunnel okay), please see https://github.com/mobile-next/mobile-mcp/wiki/',
);
}
const wda = new WebDriverAgent('localhost', WDA_PORT);
if (!(await wda.isRunning())) {
throw new ActionableError(
'WebDriverAgent is not running on device (tunnel okay, port forwarding okay), please see https://github.com/mobile-next/mobile-mcp/wiki/',
);
}
return wda;
}
private async ios(...args: string[]): Promise<string> {
return execFileSync(
getGoIosPath(),
['--udid', this.deviceId, ...args],
{},
).toString();
}
public async getIosVersion(): Promise<string> {
const output = await this.ios('info');
const json = JSON.parse(output);
return json.ProductVersion;
}
private async isTunnelRequired(): Promise<boolean> {
const version = await this.getIosVersion();
const args = version.split('.');
return parseInt(args[0], 10) >= 17;
}
public async getScreenSize(): Promise<ScreenSize> {
const wda = await this.wda();
return await wda.getScreenSize();
}
public async swipe(direction: SwipeDirection): Promise<void> {
const wda = await this.wda();
await wda.swipe(direction);
}
public async swipeFromCoordinate(
x: number,
y: number,
direction: SwipeDirection,
distance?: number,
): Promise<void> {
const wda = await this.wda();
await wda.swipeFromCoordinate(x, y, direction, distance);
}
public async listApps(): Promise<InstalledApp[]> {
await this.assertTunnelRunning();
const output = await this.ios('apps', '--all', '--list');
return output.split('\n').map((line) => {
const [packageName, appName] = line.split(' ');
return {
packageName,
appName,
};
});
}
public async launchApp(packageName: string, locale?: string): Promise<void> {
validatePackageName(packageName);
await this.assertTunnelRunning();
const args = ['launch', packageName];
if (locale) {
validateLocale(locale);
const locales = locale.split(',').map((l) => l.trim());
args.push('-AppleLanguages', `(${locales.join(', ')})`);
args.push('-AppleLocale', locales[0]);
}
await this.ios(...args);
}
public async terminateApp(packageName: string): Promise<void> {
validatePackageName(packageName);
await this.assertTunnelRunning();
await this.ios('kill', packageName);
}
public async installApp(
path: string,
_options?: InstallOptions,
): Promise<void> {
await this.assertTunnelRunning();
try {
await this.ios('install', '--path', path);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
}
}
public async uninstallApp(bundleId: string): Promise<void> {
await this.assertTunnelRunning();
try {
await this.ios('uninstall', '--bundleid', bundleId);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
}
}
public async openUrl(url: string): Promise<void> {
const wda = await this.wda();
await wda.openUrl(url);
}
public async sendKeys(text: string): Promise<void> {
const wda = await this.wda();
await wda.sendKeys(text);
}
public async pressButton(button: Button): Promise<void> {
const wda = await this.wda();
await wda.pressButton(button);
}
public async tap(x: number, y: number): Promise<void> {
const wda = await this.wda();
await wda.tap(x, y);
}
public async doubleTap(x: number, y: number): Promise<void> {
const wda = await this.wda();
await wda.doubleTap(x, y);
}
public async longPress(
x: number,
y: number,
duration: number,
): Promise<void> {
const wda = await this.wda();
await wda.longPress(x, y, duration);
}
public async getElementsOnScreen(): Promise<ScreenElement[]> {
const wda = await this.wda();
return await wda.getElementsOnScreen();
}
public async getScreenshot(): Promise<Buffer> {
const wda = await this.wda();
return await wda.getScreenshot();
/* alternative:
await this.assertTunnelRunning();
const tmpFilename = path.join(tmpdir(), `screenshot-${randomBytes(8).toString("hex")}.png`);
await this.ios("screenshot", "--output", tmpFilename);
const buffer = readFileSync(tmpFilename);
unlinkSync(tmpFilename);
return buffer;
*/
}
public async setOrientation(orientation: Orientation): Promise<void> {
const wda = await this.wda();
await wda.setOrientation(orientation);
}
public async getOrientation(): Promise<Orientation> {
const wda = await this.wda();
return await wda.getOrientation();
}
}
export class IosManager {
public isGoIosInstalled(): boolean {
try {
const output = execFileSync(getGoIosPath(), ['version'], {
stdio: ['pipe', 'pipe', 'ignore'],
}).toString();
const json: VersionCommandOutput = JSON.parse(output);
return (
json.version !== undefined &&
(/^v?\d+\.\d+\.\d+/.test(json.version) ||
json.version === 'local-build')
);
} catch (error) {
return false;
}
}
public getDeviceName(deviceId: string): string {
const output = execFileSync(getGoIosPath(), [
'info',
'--udid',
deviceId,
]).toString();
const json: InfoCommandOutput = JSON.parse(output);
return json.DeviceName;
}
public getDeviceInfo(deviceId: string): InfoCommandOutput {
const output = execFileSync(getGoIosPath(), [
'info',
'--udid',
deviceId,
]).toString();
const json: InfoCommandOutput = JSON.parse(output);
return json;
}
public listDevices(): IosDevice[] {
if (!this.isGoIosInstalled()) {
console.error(
'go-ios is not installed, no physical iOS devices can be detected',
);
return [];
}
const output = execFileSync(getGoIosPath(), ['list']).toString();
const json: ListCommandOutput = JSON.parse(output);
const devices = json.deviceList.map((device) => ({
deviceId: device,
deviceName: this.getDeviceName(device),
}));
return devices;
}
public listDevicesWithDetails(): Array<IosDevice & { version: string }> {
if (!this.isGoIosInstalled()) {
console.error(
'go-ios is not installed, no physical iOS devices can be detected',
);
return [];
}
const output = execFileSync(getGoIosPath(), ['list']).toString();
const json: ListCommandOutput = JSON.parse(output);
const devices = json.deviceList.map((device) => {
const info = this.getDeviceInfo(device);
return {
deviceId: device,
deviceName: info.DeviceName,
version: info.ProductVersion,
};
});
return devices;
}
}

View file

@ -0,0 +1,312 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, basename, extname } from 'node:path';
import { trace } from './logger';
import { WebDriverAgent } from './webdriver-agent';
import {
ActionableError,
Button,
InstalledApp,
InstallOptions,
Robot,
ScreenElement,
ScreenSize,
SwipeDirection,
Orientation,
} from './robot';
import { validatePackageName, validateLocale } from './utils';
export interface Simulator {
name: string;
uuid: string;
state: string;
}
interface AppInfo {
ApplicationType: string;
Bundle: string;
CFBundleDisplayName: string;
CFBundleExecutable: string;
CFBundleIdentifier: string;
CFBundleName: string;
CFBundleVersion: string;
DataContainer: string;
Path: string;
}
const TIMEOUT = 30000;
const WDA_PORT = 8100;
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
export class Simctl implements Robot {
constructor(private readonly simulatorUuid: string) {}
private async isWdaInstalled(): Promise<boolean> {
const apps = await this.listApps();
return apps
.map((app) => app.packageName)
.includes('com.facebook.WebDriverAgentRunner.xctrunner');
}
private async startWda(): Promise<void> {
if (!(await this.isWdaInstalled())) {
// wda is not even installed, won't attempt to start it
return;
}
trace('Starting WebDriverAgent');
const webdriverPackageName = 'com.facebook.WebDriverAgentRunner.xctrunner';
this.simctl('launch', this.simulatorUuid, webdriverPackageName);
// now we wait for wda to have a successful status
const wda = new WebDriverAgent('localhost', WDA_PORT);
// wait up to 10 seconds for wda to start
const timeout = +new Date() + 10 * 1000;
while (+new Date() < timeout) {
// cross fingers and see if wda is already running
if (await wda.isRunning()) {
trace('WebDriverAgent is now running');
return;
}
// wait 100ms before trying again
await new Promise((resolve) => setTimeout(resolve, 100));
}
trace('Could not start WebDriverAgent in time, giving up');
}
private async wda(): Promise<WebDriverAgent> {
const wda = new WebDriverAgent('localhost', WDA_PORT);
if (!(await wda.isRunning())) {
await this.startWda();
if (!(await wda.isRunning())) {
throw new ActionableError(
'WebDriverAgent is not running on simulator, please see https://github.com/mobile-next/mobile-mcp/wiki/',
);
}
// was successfully started
}
return wda;
}
private simctl(...args: string[]): Buffer {
return execFileSync('xcrun', ['simctl', ...args], {
timeout: TIMEOUT,
maxBuffer: MAX_BUFFER_SIZE,
});
}
public async getScreenshot(): Promise<Buffer> {
const wda = await this.wda();
return await wda.getScreenshot();
// alternative: return this.simctl("io", this.simulatorUuid, "screenshot", "-");
}
public async openUrl(url: string) {
const wda = await this.wda();
await wda.openUrl(url);
// alternative: this.simctl("openurl", this.simulatorUuid, url);
}
public async launchApp(packageName: string, locale?: string) {
validatePackageName(packageName);
const args = ['launch', this.simulatorUuid, packageName];
if (locale) {
validateLocale(locale);
const locales = locale.split(',').map((l) => l.trim());
args.push('-AppleLanguages', `(${locales.join(', ')})`);
args.push('-AppleLocale', locales[0]);
}
this.simctl(...args);
}
public async terminateApp(packageName: string) {
validatePackageName(packageName);
this.simctl('terminate', this.simulatorUuid, packageName);
}
private findAppBundle(dir: string): string | null {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.endsWith('.app')) {
return join(dir, entry.name);
}
}
return null;
}
private validateZipPaths(zipPath: string): void {
const output = execFileSync('/usr/bin/zipinfo', ['-1', zipPath], {
timeout: TIMEOUT,
maxBuffer: MAX_BUFFER_SIZE,
}).toString();
const invalidPath = output
.split('\n')
.map((s) => s.trim())
.filter((s) => s)
.find((s) => s.startsWith('/') || s.includes('..'));
if (invalidPath) {
throw new ActionableError(
`Security violation: File path '${invalidPath}' contains invalid characters`,
);
}
}
public async installApp(
path: string,
_options?: InstallOptions,
): Promise<void> {
let tempDir: string | null = null;
let installPath = path;
try {
// zip files need to be extracted prior to installation
if (extname(path).toLowerCase() === '.zip') {
trace(`Detected .zip file, validating contents`);
// before extracting, let's make sure there's no zip-slip bombs here
this.validateZipPaths(path);
tempDir = mkdtempSync(join(tmpdir(), 'ios-app-'));
try {
execFileSync('unzip', ['-q', path, '-d', tempDir], {
timeout: TIMEOUT,
});
} catch (error: any) {
throw new ActionableError(`Failed to unzip file: ${error.message}`);
}
const appBundle = this.findAppBundle(tempDir);
if (!appBundle) {
throw new ActionableError(
'No .app bundle found in the .zip file, please visit wiki at https://github.com/mobile-next/mobile-mcp/wiki for assistance.',
);
}
installPath = appBundle;
trace(`Found .app bundle at: ${basename(appBundle)}`);
}
// continue with installation
this.simctl('install', this.simulatorUuid, installPath);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
} finally {
// Clean up temporary directory if it was created
if (tempDir) {
try {
trace(`Cleaning up temporary directory`);
rmSync(tempDir, { recursive: true, force: true });
} catch (cleanupError) {
trace(
`Warning: Failed to cleanup temporary directory: ${cleanupError}`,
);
}
}
}
}
public async uninstallApp(bundleId: string): Promise<void> {
try {
this.simctl('uninstall', this.simulatorUuid, bundleId);
} catch (error: any) {
const stdout = error.stdout ? error.stdout.toString() : '';
const stderr = error.stderr ? error.stderr.toString() : '';
const output = (stdout + stderr).trim();
throw new ActionableError(output || error.message);
}
}
public async listApps(): Promise<InstalledApp[]> {
const text = this.simctl('listapps', this.simulatorUuid).toString();
const result = execFileSync(
'plutil',
['-convert', 'json', '-o', '-', '-r', '-'],
{
input: text,
},
);
const output = JSON.parse(result.toString()) as Record<string, AppInfo>;
return Object.values(output).map((app) => ({
packageName: app.CFBundleIdentifier,
appName: app.CFBundleDisplayName,
}));
}
public async getScreenSize(): Promise<ScreenSize> {
const wda = await this.wda();
return wda.getScreenSize();
}
public async sendKeys(keys: string) {
const wda = await this.wda();
return wda.sendKeys(keys);
}
public async swipe(direction: SwipeDirection): Promise<void> {
const wda = await this.wda();
return wda.swipe(direction);
}
public async swipeFromCoordinate(
x: number,
y: number,
direction: SwipeDirection,
distance?: number,
): Promise<void> {
const wda = await this.wda();
return wda.swipeFromCoordinate(x, y, direction, distance);
}
public async tap(x: number, y: number) {
const wda = await this.wda();
return wda.tap(x, y);
}
public async doubleTap(x: number, y: number): Promise<void> {
const wda = await this.wda();
await wda.doubleTap(x, y);
}
public async longPress(x: number, y: number, duration: number) {
const wda = await this.wda();
return wda.longPress(x, y, duration);
}
public async pressButton(button: Button) {
const wda = await this.wda();
return wda.pressButton(button);
}
public async getElementsOnScreen(): Promise<ScreenElement[]> {
const wda = await this.wda();
return wda.getElementsOnScreen();
}
public async setOrientation(orientation: Orientation): Promise<void> {
const wda = await this.wda();
return wda.setOrientation(orientation);
}
public async getOrientation(): Promise<Orientation> {
const wda = await this.wda();
return wda.getOrientation();
}
}

View file

@ -0,0 +1,21 @@
import { appendFileSync } from "node:fs";
const writeLog = (message: string) => {
if (process.env.LOG_FILE) {
const logfile = process.env.LOG_FILE;
const timestamp = new Date().toISOString();
const levelStr = "INFO";
const logMessage = `[${timestamp}] ${levelStr} ${message}`;
appendFileSync(logfile, logMessage + "\n");
}
console.error(message);
};
export const trace = (message: string) => {
writeLog(message);
};
export const error = (message: string) => {
writeLog(message);
};

View file

@ -0,0 +1,263 @@
import { Mobilecli } from './mobilecli';
import {
Button,
InstalledApp,
InstallOptions,
Orientation,
Robot,
ScreenElement,
ScreenSize,
SwipeDirection,
} from './robot';
interface InstalledAppsResponse {
status: 'ok';
data: Array<{
packageName: string;
appName?: string; // ios
version?: string; // ios
}>;
}
interface DeviceInfoResponse {
status: 'ok';
data: {
device: {
id: string;
name: string;
platform: string;
type: string;
version: string;
state: string;
screenSize?: {
width: number;
height: number;
scale: number;
};
};
};
}
interface UIElementResponse {
type: string;
label?: string;
text?: string;
name?: string;
value?: string;
identifier?: string;
rect: {
x: number;
y: number;
width: number;
height: number;
};
focused?: boolean;
}
interface DumpUIResponse {
status: 'ok';
data: {
elements: UIElementResponse[];
};
}
interface OrientationResponse {
status: 'ok';
data: {
orientation: Orientation;
};
}
export class MobileDevice implements Robot {
private mobilecli: Mobilecli;
public constructor(private deviceId: string) {
this.mobilecli = new Mobilecli();
}
private runCommand(args: string[]): string {
const fullArgs = [...args, '--device', this.deviceId];
return this.mobilecli.executeCommand(fullArgs);
}
public async getScreenSize(): Promise<ScreenSize> {
const response = JSON.parse(
this.runCommand(['device', 'info']),
) as DeviceInfoResponse;
if (response.data.device.screenSize) {
return response.data.device.screenSize;
}
return { width: 0, height: 0, scale: 1.0 };
}
public async swipe(direction: SwipeDirection): Promise<void> {
const screenSize = await this.getScreenSize();
const centerX = Math.floor(screenSize.width / 2);
const centerY = Math.floor(screenSize.height / 2);
const distance = 400; // Default distance in pixels
let startX = centerX;
let startY = centerY;
let endX = centerX;
let endY = centerY;
switch (direction) {
case 'up':
startY = centerY + distance / 2;
endY = centerY - distance / 2;
break;
case 'down':
startY = centerY - distance / 2;
endY = centerY + distance / 2;
break;
case 'left':
startX = centerX + distance / 2;
endX = centerX - distance / 2;
break;
case 'right':
startX = centerX - distance / 2;
endX = centerX + distance / 2;
break;
}
this.runCommand(['io', 'swipe', `${startX},${startY},${endX},${endY}`]);
}
public async swipeFromCoordinate(
x: number,
y: number,
direction: SwipeDirection,
distance?: number,
): Promise<void> {
const swipeDistance = distance || 400;
let endX = x;
let endY = y;
switch (direction) {
case 'up':
endY = y - swipeDistance;
break;
case 'down':
endY = y + swipeDistance;
break;
case 'left':
endX = x - swipeDistance;
break;
case 'right':
endX = x + swipeDistance;
break;
}
this.runCommand(['io', 'swipe', `${x},${y},${endX},${endY}`]);
}
public async getScreenshot(): Promise<Buffer> {
const fullArgs = [
'screenshot',
'--device',
this.deviceId,
'--format',
'png',
'--output',
'-',
];
return this.mobilecli.executeCommandBuffer(fullArgs);
}
public async listApps(): Promise<InstalledApp[]> {
const response = JSON.parse(
this.runCommand(['apps', 'list']),
) as InstalledAppsResponse;
return response.data.map((app) => ({
appName: app.appName || app.packageName,
packageName: app.packageName,
})) as InstalledApp[];
}
public async launchApp(packageName: string, locale?: string): Promise<void> {
const args = ['apps', 'launch', packageName];
if (locale) {
args.push('--locale', locale);
}
this.runCommand(args);
}
public async terminateApp(packageName: string): Promise<void> {
this.runCommand(['apps', 'terminate', packageName]);
}
public async installApp(
path: string,
_options?: InstallOptions,
): Promise<void> {
this.runCommand(['apps', 'install', path]);
}
public async uninstallApp(bundleId: string): Promise<void> {
this.runCommand(['apps', 'uninstall', bundleId]);
}
public async openUrl(url: string): Promise<void> {
this.runCommand(['url', url]);
}
public async sendKeys(text: string): Promise<void> {
this.runCommand(['io', 'text', text]);
}
public async pressButton(button: Button): Promise<void> {
this.runCommand(['io', 'button', button]);
}
public async tap(x: number, y: number): Promise<void> {
this.runCommand(['io', 'tap', `${x},${y}`]);
}
public async doubleTap(x: number, y: number): Promise<void> {
// TODO: should move into mobilecli itself as "io doubletap"
await this.tap(x, y);
await this.tap(x, y);
}
public async longPress(
x: number,
y: number,
duration: number,
): Promise<void> {
this.runCommand([
'io',
'longpress',
`${x},${y}`,
'--duration',
`${duration}`,
]);
}
public async getElementsOnScreen(): Promise<ScreenElement[]> {
const response = JSON.parse(
this.runCommand(['dump', 'ui']),
) as DumpUIResponse;
return response.data.elements.map((element) => ({
type: element.type,
label: element.label,
text: element.text,
name: element.name,
value: element.value,
identifier: element.identifier,
rect: element.rect,
focused: element.focused,
}));
}
public async setOrientation(orientation: Orientation): Promise<void> {
this.runCommand(['device', 'orientation', 'set', orientation]);
}
public async getOrientation(): Promise<Orientation> {
const response = JSON.parse(
this.runCommand(['device', 'orientation', 'get']),
) as OrientationResponse;
return response.data.orientation;
}
}

View file

@ -0,0 +1,207 @@
import { existsSync } from "node:fs";
import { dirname, join, sep } from "node:path";
import { execFileSync, spawn, ChildProcess } from "node:child_process";
export interface MobilecliCrashEntry {
processName: string;
timestamp: string;
id: string;
}
export interface MobilecliCrashesListResponse {
status: "ok";
data: MobilecliCrashEntry[];
}
export interface MobilecliCrashGetResponse {
status: "ok";
data: {
content: string;
id: string;
};
}
export interface MobilecliAgentStatusResponse {
status: "ok" | "fail";
data: {
message: string;
};
}
export interface MobilecliDevicesOptions {
includeOffline?: boolean;
platform?: "ios" | "android";
type?: "real" | "emulator" | "simulator";
}
export interface MobilecliDeviceProvider {
type: string; // e.g. "mobilefleet" for remote devices
allocationId?: string;
}
export interface MobilecliDevice {
id: string;
name: string;
platform: "android" | "ios";
type: "real" | "emulator" | "simulator";
version: string;
provider?: MobilecliDeviceProvider;
}
export interface MobilecliDevicesResponse {
status: "ok";
data: {
devices: MobilecliDevice[];
};
}
const TIMEOUT = 30000;
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
export class Mobilecli {
private path: string | null = null;
constructor() { }
private getPath(): string {
if (!this.path) {
this.path = Mobilecli.getMobilecliPath();
}
return this.path;
}
public executeCommand(args: string[]): string {
const path = this.getPath();
return execFileSync(path, args, { encoding: "utf8" }).toString().trim();
}
public spawnCommand(args: string[]): ChildProcess {
const binaryPath = this.getPath();
return spawn(binaryPath, args, {
stdio: ["ignore", "ignore", "ignore"],
});
}
public executeCommandBuffer(args: string[]): Buffer {
const path = this.getPath();
return execFileSync(path, args, {
encoding: "buffer",
maxBuffer: MAX_BUFFER_SIZE,
timeout: TIMEOUT,
}) as Buffer;
}
private static getMobilecliPath(): string {
if (process.env.MOBILECLI_PATH) {
return process.env.MOBILECLI_PATH;
}
const platform = process.platform;
const arch = process.arch;
const normalizedPlatform = platform === "win32" ? "windows" : platform;
const normalizedArch = arch === "arm64" ? "arm64" : "amd64";
const ext = platform === "win32" ? ".exe" : "";
const binaryName = `mobilecli-${normalizedPlatform}-${normalizedArch}${ext}`;
// Check if mobile-mcp is installed as a package
const currentPath = __filename;
const pathParts = currentPath.split(sep);
const lastNodeModulesIndex = pathParts.lastIndexOf("node_modules");
if (lastNodeModulesIndex !== -1) {
// We're inside node_modules, go to the last node_modules in the path
const nodeModulesParts = pathParts.slice(0, lastNodeModulesIndex + 1);
const lastNodeModulesPath = nodeModulesParts.join(sep);
const mobilecliPath = join(lastNodeModulesPath, "mobilecli", "bin", binaryName);
if (existsSync(mobilecliPath)) {
return mobilecliPath;
}
}
// Not in node_modules, look one directory up from current script
const scriptDir = dirname(__filename);
const parentDir = dirname(scriptDir);
const mobilecliPath = join(parentDir, "node_modules", "mobilecli", "bin", binaryName);
if (existsSync(mobilecliPath)) {
return mobilecliPath;
}
throw new Error(`Could not find mobilecli binary for platform: ${platform}`);
}
getVersion(): string {
try {
const output = this.executeCommand(["--version"]);
if (output.startsWith("mobilecli version ")) {
return output.substring("mobilecli version ".length);
}
return "failed";
} catch (error: any) {
return "failed " + error.message;
}
}
remoteListDevices(): string {
return this.executeCommand(["remote", "list-devices"]);
}
remoteAllocate(platform: "ios" | "android"): string {
return this.executeCommand(["remote", "allocate", "--platform", platform]);
}
remoteRelease(deviceId: string): string {
return this.executeCommand(["remote", "release", "--device", deviceId]);
}
crashesList(deviceId: string): MobilecliCrashesListResponse {
const output = this.executeCommand(["device", "crashes", "list", "--device", deviceId]);
return JSON.parse(output) as MobilecliCrashesListResponse;
}
crashesGet(deviceId: string, id: string): MobilecliCrashGetResponse {
const output = this.executeCommandBuffer(["device", "crashes", "get", id, "--device", deviceId]);
return JSON.parse(output.toString().trim()) as MobilecliCrashGetResponse;
}
agentStatus(deviceId: string): MobilecliAgentStatusResponse {
const output = this.executeCommand(["agent", "status", "--device", deviceId]);
return JSON.parse(output) as MobilecliAgentStatusResponse;
}
agentInstall(deviceId: string): void {
this.executeCommand(["agent", "install", "--device", deviceId]);
}
getDevices(options?: MobilecliDevicesOptions): MobilecliDevicesResponse {
const args = ["devices"];
if (options) {
if (options.includeOffline) {
args.push("--include-offline");
}
if (options.platform) {
if (options.platform !== "ios" && options.platform !== "android") {
throw new Error(`Invalid platform: ${options.platform}. Must be "ios" or "android"`);
}
args.push("--platform", options.platform);
}
if (options.type) {
if (options.type !== "real" && options.type !== "emulator" && options.type !== "simulator") {
throw new Error(`Invalid type: ${options.type}. Must be "real", "emulator", or "simulator"`);
}
args.push("--type", options.type);
}
}
const mobilecliOutput = this.executeCommand(args);
return JSON.parse(mobilecliOutput) as MobilecliDevicesResponse;
}
}

View file

@ -0,0 +1,20 @@
export interface PngDimensions {
width: number;
height: number;
}
export class PNG {
public constructor(private readonly buffer: Buffer) {
}
public getDimensions(): PngDimensions {
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!this.buffer.subarray(0, 8).equals(pngSignature)) {
throw new Error("Not a valid PNG file");
}
const width = this.buffer.readUInt32BE(16);
const height = this.buffer.readUInt32BE(20);
return { width, height };
}
}

View file

@ -0,0 +1,172 @@
export interface Dimensions {
width: number;
height: number;
}
export interface ScreenSize extends Dimensions {
scale: number;
}
export interface InstalledApp {
packageName: string;
appName: string;
}
export type SwipeDirection = 'up' | 'down' | 'left' | 'right';
export type Button =
| 'HOME'
| 'BACK'
| 'VOLUME_UP'
| 'VOLUME_DOWN'
| 'ENTER'
| 'DPAD_CENTER'
| 'DPAD_UP'
| 'DPAD_DOWN'
| 'DPAD_LEFT'
| 'DPAD_RIGHT';
export interface ScreenElementRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ScreenElement {
type: string;
label?: string;
text?: string;
name?: string;
value?: string;
identifier?: string;
rect: ScreenElementRect;
// currently only on android tv
focused?: boolean;
}
export class ActionableError extends Error {
constructor(message: string) {
super(message);
}
}
export interface InstallOptions {
/** -r: replace existing application (default true for backward compatibility) */
replace?: boolean;
/** -g: grant all runtime permissions on install */
grantPermissions?: boolean;
/** -d: allow version code downgrade */
allowDowngrade?: boolean;
/** -t: allow test APKs */
allowTest?: boolean;
}
export type Orientation = 'portrait' | 'landscape';
export interface Robot {
/**
* Get the screen size of the device in pixels.
*/
getScreenSize(): Promise<ScreenSize>;
/**
* Swipe in a direction.
*/
swipe(direction: SwipeDirection): Promise<void>;
/**
* Swipe from a specific coordinate in a direction.
*/
swipeFromCoordinate(
x: number,
y: number,
direction: SwipeDirection,
distance?: number,
): Promise<void>;
/**
* Get a screenshot of the screen. Returns a Buffer that contains
* a PNG image of the screen. Will be same dimensions as getScreenSize().
*/
getScreenshot(): Promise<Buffer>;
/**
* List all installed apps on the device. Returns an array of package names (or
* bundle identifiers in iOS) for all installed apps.
*/
listApps(): Promise<InstalledApp[]>;
/**
* Launch an app.
*/
launchApp(packageName: string, locale?: string): Promise<void>;
/**
* Terminate an app. If app was already terminated (or non existent) then this
* is a no-op.
*/
terminateApp(packageName: string): Promise<void>;
/**
* Install an app on the device from a file path.
*/
installApp(path: string, options?: InstallOptions): Promise<void>;
/**
* Uninstall an app from the device.
*/
uninstallApp(bundleId: string): Promise<void>;
/**
* Open a URL in the device's web browser. Can be an https:// url, or a
* custom scheme (e.g. "myapp://").
*/
openUrl(url: string): Promise<void>;
/**
* Send keys to the device, simulating keyboard input.
*/
sendKeys(text: string): Promise<void>;
/**
* Press a button on the device, simulating a physical button press.
*/
pressButton(button: Button): Promise<void>;
/**
* Tap on a specific coordinate on the screen.
*/
tap(x: number, y: number): Promise<void>;
/**
* Tap on a specific coordinate on the screen.
*/
doubleTap(x: number, y: number): Promise<void>;
/**
* Long press on a specific coordinate on the screen.
* @param x - The x coordinate to long press
* @param y - The y coordinate to long press
* @param duration - Duration of the long press in milliseconds
*/
longPress(x: number, y: number, duration: number): Promise<void>;
/**
* Get all elements on the screen. Works only on native apps (not webviews). Will
* return a filtered list of elements that make sense to interact with.
*/
getElementsOnScreen(): Promise<ScreenElement[]>;
/**
* Change the screen orientation of the device.
* @param orientation The desired orientation ("portrait" or "landscape")
*/
setOrientation(orientation: Orientation): Promise<void>;
/**
* Get the current screen orientation.
*/
getOrientation(): Promise<Orientation>;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,88 @@
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { ActionableError } from "./robot";
export function validatePackageName(packageName: string): void {
if (!/^[a-zA-Z0-9._]+$/.test(packageName)) {
throw new ActionableError(`Invalid package name: "${packageName}"`);
}
}
export function validateLocale(locale: string): void {
if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) {
throw new ActionableError(`Invalid locale: "${locale}"`);
}
}
function getAllowedRoots(): string[] {
const roots = [
os.tmpdir(),
process.cwd(),
];
// macOS /tmp is a symlink to /private/tmp, add both to be safe
if (process.platform === "darwin") {
roots.push("/tmp");
roots.push("/private/tmp");
}
return roots.map(r => path.resolve(r));
}
function isPathUnderRoot(filePath: string, root: string): boolean {
const relative = path.relative(root, filePath);
if (relative === "") {
return false;
}
if (path.isAbsolute(relative)) {
return false;
}
if (relative.startsWith("..")) {
return false;
}
return true;
}
export function validateFileExtension(filePath: string, allowedExtensions: string[], toolName: string): void {
const ext = path.extname(filePath).toLowerCase();
if (!allowedExtensions.includes(ext)) {
throw new ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`);
}
}
function resolveWithSymlinks(filePath: string): string {
const resolved = path.resolve(filePath);
const dir = path.dirname(resolved);
const filename = path.basename(resolved);
try {
return path.join(fs.realpathSync(dir), filename);
} catch {
return resolved;
}
}
export function validateOutputPath(filePath: string): void {
const resolved = resolveWithSymlinks(filePath);
const allowedRoots = getAllowedRoots();
const isWindows = process.platform === "win32";
const isAllowed = allowedRoots.some(root => {
if (isWindows) {
return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase());
}
return isPathUnderRoot(resolved, root);
});
if (!isAllowed) {
const dir = path.dirname(resolved);
throw new ActionableError(
`"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`
);
}
}

View file

@ -0,0 +1,454 @@
import { ActionableError, SwipeDirection, ScreenSize, ScreenElement, Orientation } from "./robot";
export interface SourceTreeElementRect {
x: number;
y: number;
width: number;
height: number;
}
export interface SourceTreeElement {
type: string;
label?: string;
name?: string;
value?: string;
rawIdentifier?: string;
rect: SourceTreeElementRect;
isVisible?: string; // "0" or "1"
children?: Array<SourceTreeElement>;
}
export interface SourceTree {
value: SourceTreeElement;
}
export class WebDriverAgent {
constructor(private readonly host: string, private readonly port: number) {
}
public async isRunning(): Promise<boolean> {
const url = `http://${this.host}:${this.port}/status`;
try {
const response = await fetch(url);
const json = await response.json();
return response.status === 200 && json.value?.ready === true;
} catch (error) {
// console.error(`Failed to connect to WebDriverAgent: ${error}`);
return false;
}
}
public async createSession(): Promise<string> {
const url = `http://${this.host}:${this.port}/session`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ capabilities: { alwaysMatch: { platformName: "iOS" } } }),
});
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(`Failed to create WebDriver session: ${response.status} ${errorText}`);
}
const json = await response.json();
if (!json.value || !json.value.sessionId) {
throw new ActionableError(`Invalid session response: ${JSON.stringify(json)}`);
}
return json.value.sessionId;
}
public async deleteSession(sessionId: string) {
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
const response = await fetch(url, { method: "DELETE" });
return response.json();
}
public async withinSession(fn: (url: string) => Promise<any>) {
const sessionId = await this.createSession();
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
const result = await fn(url);
await this.deleteSession(sessionId);
return result;
}
public async getScreenSize(sessionUrl?: string): Promise<ScreenSize> {
if (sessionUrl) {
const url = `${sessionUrl}/wda/screen`;
const response = await fetch(url);
const json = await response.json();
return {
width: json.value.screenSize.width,
height: json.value.screenSize.height,
scale: json.value.scale || 1,
};
} else {
return this.withinSession(async sessionUrlInner => {
const url = `${sessionUrlInner}/wda/screen`;
const response = await fetch(url);
const json = await response.json();
return {
width: json.value.screenSize.width,
height: json.value.screenSize.height,
scale: json.value.scale || 1,
};
});
}
}
public async sendKeys(keys: string) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/wda/keys`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ value: [keys] }),
});
});
}
public async pressButton(button: string) {
const _map = {
"HOME": "home",
"VOLUME_UP": "volumeup",
"VOLUME_DOWN": "volumedown",
};
if (button === "ENTER") {
await this.sendKeys("\n");
return;
}
// Type assertion to check if button is a key of _map
if (!(button in _map)) {
throw new ActionableError(`Button "${button}" is not supported`);
}
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/wda/pressButton`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: button,
}),
});
return response.json();
});
}
public async tap(x: number, y: number) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x, y },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 100 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
});
}
public async doubleTap(x: number, y: number) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x, y },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 50 },
{ type: "pointerUp", button: 0 },
{ type: "pause", duration: 100 },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 50 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
});
}
public async longPress(x: number, y: number, duration: number) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x, y },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
});
}
private isVisible(rect: SourceTreeElementRect): boolean {
return rect.x >= 0 && rect.y >= 0;
}
private filterSourceElements(source: SourceTreeElement): Array<ScreenElement> {
const output: ScreenElement[] = [];
const acceptedTypes = ["TextField", "Button", "Switch", "Icon", "SearchField", "StaticText", "Image"];
if (acceptedTypes.includes(source.type)) {
if (source.isVisible === "1" && this.isVisible(source.rect)) {
if (source.label !== null || source.name !== null || source.rawIdentifier !== null) {
output.push({
type: source.type,
label: source.label,
name: source.name,
value: source.value,
identifier: source.rawIdentifier,
rect: {
x: source.rect.x,
y: source.rect.y,
width: source.rect.width,
height: source.rect.height,
},
});
}
}
}
if (source.children) {
for (const child of source.children) {
output.push(...this.filterSourceElements(child));
}
}
return output;
}
public async getPageSource(): Promise<SourceTree> {
const url = `http://${this.host}:${this.port}/source/?format=json`;
const response = await fetch(url);
const json = await response.json();
return json as SourceTree;
}
public async getElementsOnScreen(): Promise<ScreenElement[]> {
const source = await this.getPageSource();
return this.filterSourceElements(source.value);
}
public async openUrl(url: string): Promise<void> {
await this.withinSession(async sessionUrl => {
await fetch(`${sessionUrl}/url`, {
method: "POST",
body: JSON.stringify({ url }),
});
});
}
public async getScreenshot(): Promise<Buffer> {
const url = `http://${this.host}:${this.port}/screenshot`;
const response = await fetch(url);
const json = await response.json();
return Buffer.from(json.value, "base64");
}
public async swipe(direction: SwipeDirection): Promise<void> {
await this.withinSession(async sessionUrl => {
const screenSize = await this.getScreenSize(sessionUrl);
let x0: number, y0: number, x1: number, y1: number;
// Use 60% of the width/height for swipe distance
const verticalDistance = Math.floor(screenSize.height * 0.6);
const horizontalDistance = Math.floor(screenSize.width * 0.6);
const centerX = Math.floor(screenSize.width / 2);
const centerY = Math.floor(screenSize.height / 2);
switch (direction) {
case "up":
x0 = x1 = centerX;
y0 = centerY + Math.floor(verticalDistance / 2);
y1 = centerY - Math.floor(verticalDistance / 2);
break;
case "down":
x0 = x1 = centerX;
y0 = centerY - Math.floor(verticalDistance / 2);
y1 = centerY + Math.floor(verticalDistance / 2);
break;
case "left":
y0 = y1 = centerY;
x0 = centerX + Math.floor(horizontalDistance / 2);
x1 = centerX - Math.floor(horizontalDistance / 2);
break;
case "right":
y0 = y1 = centerY;
x0 = centerX - Math.floor(horizontalDistance / 2);
x1 = centerX + Math.floor(horizontalDistance / 2);
break;
default:
throw new ActionableError(`Swipe direction "${direction}" is not supported`);
}
const url = `${sessionUrl}/actions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x: x0, y: y0 },
{ type: "pointerDown", button: 0 },
{ type: "pointerMove", duration: 1000, x: x1, y: y1 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`);
}
// Clear actions to ensure they complete
await fetch(`${sessionUrl}/actions`, {
method: "DELETE",
});
});
}
public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance: number = 400): Promise<void> {
await this.withinSession(async sessionUrl => {
// Use simple coordinates like the working swipe method
const x0 = x;
const y0 = y;
let x1 = x;
let y1 = y;
// Calculate target position based on direction and distance
switch (direction) {
case "up":
y1 = y - distance; // Move up by specified distance
break;
case "down":
y1 = y + distance; // Move down by specified distance
break;
case "left":
x1 = x - distance; // Move left by specified distance
break;
case "right":
x1 = x + distance; // Move right by specified distance
break;
default:
throw new ActionableError(`Swipe direction "${direction}" is not supported`);
}
const url = `${sessionUrl}/actions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x: x0, y: y0 },
{ type: "pointerDown", button: 0 },
{ type: "pointerMove", duration: 1000, x: x1, y: y1 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`);
}
// Clear actions to ensure they complete
await fetch(`${sessionUrl}/actions`, {
method: "DELETE",
});
});
}
public async setOrientation(orientation: Orientation): Promise<void> {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/orientation`;
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orientation: orientation.toUpperCase()
})
});
});
}
public async getOrientation(): Promise<Orientation> {
return this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/orientation`;
const response = await fetch(url);
const json = await response.json();
return json.value.toLowerCase() as Orientation;
});
}
}

View file

@ -0,0 +1,139 @@
import { test, expect } from "@playwright/test";
import { PNG } from "../src/png";
import { AndroidRobot, AndroidDeviceManager } from "../src/android";
const manager = new AndroidDeviceManager();
const devices = manager.getConnectedDevices();
const hasOneAndroidDevice = devices.length === 1;
test.describe("android", () => {
const android = new AndroidRobot(devices?.[0]?.deviceId || "");
test("should be able to get the screen size", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
const screenSize = await android.getScreenSize();
expect(screenSize.width).toBeGreaterThan(1024);
expect(screenSize.height).toBeGreaterThan(1024);
expect(screenSize.scale).toBe(1);
expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3);
});
test("should be able to take screenshot", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
const screenSize = await android.getScreenSize();
const screenshot = await android.getScreenshot();
expect(screenshot.length).toBeGreaterThan(64 * 1024);
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
expect(pngSize.width).toBe(screenSize.width);
expect(pngSize.height).toBe(screenSize.height);
});
test("should be able to list apps", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
const apps = await android.listApps();
const packages = apps.map(app => app.packageName);
expect(packages).toContain("com.android.settings");
});
test("should be able to open a url", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
await android.adb("shell", "input", "keyevent", "HOME");
await android.openUrl("https://www.example.com");
});
test("should be able to list elements on screen", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
await android.terminateApp("com.android.chrome");
await android.adb("shell", "input", "keyevent", "HOME");
await android.openUrl("https://www.example.com");
const elements = await android.getElementsOnScreen();
// make sure title (TextView) is present
const foundTitle = elements.find(element => element.type === "android.widget.TextView" && element.text?.startsWith("This domain is for use in illustrative examples in documents"));
expect(foundTitle, "Title element not found").toBeTruthy();
// make sure navbar (EditText) is present
const foundNavbar = elements.find(element => element.type === "android.widget.EditText" && element.label === "Search or type URL" && element.text === "example.com");
expect(foundNavbar, "Navbar element not found").toBeTruthy();
// this is an icon, but has accessibility label
const foundSecureIcon = elements.find(element => element.type === "android.widget.ImageButton" && element.text === "" && element.label === "New tab");
expect(foundSecureIcon, "New tab icon not found").toBeTruthy();
});
test("should be able to send keys and tap", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
await android.terminateApp("com.google.android.deskclock");
await android.adb("shell", "pm", "clear", "com.google.android.deskclock");
await android.launchApp("com.google.android.deskclock");
// We probably start at Clock tab
await new Promise(resolve => setTimeout(resolve, 3000));
let elements = await android.getElementsOnScreen();
const timerElement = elements.find(e => e.label === "Timer" && e.type === "android.widget.FrameLayout");
expect(timerElement).toBeDefined();
await android.tap(timerElement.rect.x, timerElement.rect.y);
// now we're in Timer tab
await new Promise(resolve => setTimeout(resolve, 3000));
elements = await android.getElementsOnScreen();
const currentTime = elements.find(e => e.text === "00h 00m 00s");
expect(currentTime, "Expected time to be 00h 00m 00s").toBeDefined();
await android.sendKeys("123456");
// now the title has changed with new timer
await new Promise(resolve => setTimeout(resolve, 3000));
elements = await android.getElementsOnScreen();
const newTime = elements.find(e => e.text === "12h 34m 56s");
expect(newTime, "Expected time to be 12h 34m 56s").toBeDefined();
await android.terminateApp("com.google.android.deskclock");
});
test("should be able to launch and terminate an app", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
// kill if running
await android.terminateApp("com.android.chrome");
await android.launchApp("com.android.chrome");
await new Promise(resolve => setTimeout(resolve, 3000));
const processes = await android.listRunningProcesses();
expect(processes).toContain("com.android.chrome");
await android.terminateApp("com.android.chrome");
const processes2 = await android.listRunningProcesses();
expect(processes2).not.toContain("com.android.chrome");
});
test("should handle orientation changes", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
// assume we start in portrait
const originalOrientation = await android.getOrientation();
expect(originalOrientation).toBe("portrait");
const screenSize1 = await android.getScreenSize();
// set to landscape
await android.setOrientation("landscape");
await new Promise(resolve => setTimeout(resolve, 1500));
const orientation = await android.getOrientation();
expect(orientation).toBe("landscape");
const screenSize2 = await android.getScreenSize();
// set to portrait
await android.setOrientation("portrait");
await new Promise(resolve => setTimeout(resolve, 1500));
const orientation2 = await android.getOrientation();
expect(orientation2).toBe("portrait");
// screen size should not have changed
expect(screenSize1).toEqual(screenSize2);
});
});

View file

@ -0,0 +1,347 @@
import { test, expect } from '@playwright/test';
// We need to test coord-norm functions. Since the module uses process.env,
// we manipulate env vars in each test.
// Dynamic import to allow env var manipulation before module evaluation.
async function loadCoordNorm(env: Record<string, string | undefined> = {}) {
const origEnv: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(env)) {
origEnv[k] = process.env[k];
if (v === undefined) {
delete process.env[k];
} else {
process.env[k] = v;
}
}
// Force re-import by clearing require cache
const modulePath = require.resolve('../src/coord-norm');
delete require.cache[modulePath];
const mod = require('../src/coord-norm');
return {
mod,
restore: () => {
for (const [k, v] of Object.entries(origEnv)) {
if (v === undefined) {
delete process.env[k];
} else {
process.env[k] = v;
}
}
delete require.cache[modulePath];
},
};
}
// ── Scalar conversion tests ──
test('normToPx maps midpoint', async () => {
const { mod, restore } = await loadCoordNorm();
try {
expect(mod.normToPx(500, 800, 1000)).toBe(400);
} finally {
restore();
}
});
test('normToPx maps edges', async () => {
const { mod, restore } = await loadCoordNorm();
try {
expect(mod.normToPx(0, 800, 1000)).toBe(0);
expect(mod.normToPx(1000, 800, 1000)).toBe(800);
} finally {
restore();
}
});
test('normToPx rounds to nearest', async () => {
const { mod, restore } = await loadCoordNorm();
try {
// 333/1000 * 800 = 266.4 → 266
expect(mod.normToPx(333, 800, 1000)).toBe(266);
} finally {
restore();
}
});
test('pxToNorm is inverse at midpoint', async () => {
const { mod, restore } = await loadCoordNorm();
try {
expect(mod.pxToNorm(400, 800, 1000)).toBe(500);
expect(mod.pxToNorm(800, 800, 1000)).toBe(1000);
} finally {
restore();
}
});
test('normToPx respects custom scale (999)', async () => {
const { mod, restore } = await loadCoordNorm();
try {
expect(mod.normToPx(999, 800, 999)).toBe(800);
// Same input under scale 1000: 999/1000*800 = 799.2 → 799
expect(mod.normToPx(999, 800, 1000)).toBe(799);
} finally {
restore();
}
});
test('pxToNorm handles zero dim', async () => {
const { mod, restore } = await loadCoordNorm();
try {
expect(mod.pxToNorm(400, 0, 1000)).toBe(0);
} finally {
restore();
}
});
// ── denormalizeArgs tests ──
test('denormalizeArgs click uses width for x, height for y', async () => {
const { mod, restore } = await loadCoordNorm();
try {
const args: any = { device: 'd1', x: 500, y: 500 };
mod.denormalizeArgs(
'mobile_click_on_screen_at_coordinates',
args,
800,
600,
);
expect(args.x).toBe(400); // 500/1000 * 800
expect(args.y).toBe(300); // 500/1000 * 600
} finally {
restore();
}
});
test('denormalizeArgs swipe converts x, y, and distance by direction', async () => {
const { mod, restore } = await loadCoordNorm();
try {
const argsDown: any = {
device: 'd1',
x: 500,
y: 500,
distance: 200,
direction: 'down',
};
mod.denormalizeArgs('mobile_swipe_on_screen', argsDown, 800, 600);
expect(argsDown.x).toBe(400);
expect(argsDown.y).toBe(300);
expect(argsDown.distance).toBe(120); // 200/1000 * 600 (height for down)
const argsRight: any = {
device: 'd1',
x: 500,
y: 500,
distance: 200,
direction: 'right',
};
mod.denormalizeArgs('mobile_swipe_on_screen', argsRight, 800, 600);
expect(argsRight.distance).toBe(160); // 200/1000 * 800 (width for right)
} finally {
restore();
}
});
test('denormalizeArgs leaves non-coord tools untouched', async () => {
const { mod, restore } = await loadCoordNorm();
try {
const args: any = { device: 'd1', direction: 'down' };
mod.denormalizeArgs('mobile_press_button', args, 800, 600);
expect(args).toEqual({ device: 'd1', direction: 'down' });
} finally {
restore();
}
});
test('denormalizeArgs ignores missing coord fields', async () => {
const { mod, restore } = await loadCoordNorm();
try {
const args: any = { device: 'd1' };
mod.denormalizeArgs(
'mobile_click_on_screen_at_coordinates',
args,
800,
600,
);
expect(args.x).toBeUndefined();
expect(args.y).toBeUndefined();
} finally {
restore();
}
});
// ── Output normalization tests ──
test('normalizeElementResult rewrites element coordinates', async () => {
const { mod, restore } = await loadCoordNorm();
try {
const elements = [
{
type: 'button',
coordinates: { x: 400, y: 300, width: 80, height: 60 },
},
];
const input = 'Found these elements on screen: ' + JSON.stringify(elements);
const result = mod.normalizeElementResult(
'mobile_list_elements_on_screen',
input,
800,
600,
);
const parsed = JSON.parse(
result.replace('Found these elements on screen: ', ''),
);
expect(parsed[0].coordinates.x).toBe(500); // 400/800*1000
expect(parsed[0].coordinates.y).toBe(500); // 300/600*1000
expect(parsed[0].coordinates.width).toBe(100); // 80/800*1000
expect(parsed[0].coordinates.height).toBe(100); // 60/600*1000
} finally {
restore();
}
});
test('normalizeScreenSizeResult rewrites to scale', async () => {
const { mod, restore } = await loadCoordNorm();
try {
const result = mod.normalizeScreenSizeResult(
'mobile_get_screen_size',
'Screen size is 1080x2400 pixels',
);
expect(result).toContain('1000x1000');
} finally {
restore();
}
});
test('normalizeScreenSizeResult leaves non-screen-size tools alone', async () => {
const { mod, restore } = await loadCoordNorm();
try {
const result = mod.normalizeScreenSizeResult(
'mobile_tap',
'Tapped at 100, 200',
);
expect(result).toBe('Tapped at 100, 200');
} finally {
restore();
}
});
// ── Screen size cache tests ──
test('screen size cache round-trip', async () => {
const { mod, restore } = await loadCoordNorm();
try {
mod.cacheScreenSize('test-device', 1080, 2400);
const size = mod.getCachedScreenSize('test-device');
expect(size).toEqual({ width: 1080, height: 2400 });
} finally {
restore();
}
});
test('screen size cache returns undefined for unknown device', async () => {
const { mod, restore } = await loadCoordNorm();
try {
expect(mod.getCachedScreenSize('nonexistent')).toBeUndefined();
} finally {
restore();
}
});
test('ingestScreenSizeFromResult parses response', async () => {
const { mod, restore } = await loadCoordNorm();
try {
mod.ingestScreenSizeFromResult('dev1', 'Screen size is 1080x2400 pixels');
const size = mod.getCachedScreenSize('dev1');
expect(size).toEqual({ width: 1080, height: 2400 });
} finally {
restore();
}
});
// ── Config tests ──
test('isNormalized returns false by default', async () => {
const { mod, restore } = await loadCoordNorm({
MOBILE_MCP_COORDINATE_SPACE: undefined,
});
try {
expect(mod.isNormalized()).toBe(false);
} finally {
restore();
}
});
test('isNormalized returns true when MOBILE_MCP_COORDINATE_SPACE=1', async () => {
const { mod, restore } = await loadCoordNorm({
MOBILE_MCP_COORDINATE_SPACE: '1',
});
try {
expect(mod.isNormalized()).toBe(true);
} finally {
restore();
}
});
test('coordinateScale defaults to 1000', async () => {
const { mod, restore } = await loadCoordNorm({
MOBILE_MCP_COORDINATE_SCALE: undefined,
});
try {
expect(mod.coordinateScale()).toBe(1000);
} finally {
restore();
}
});
test('coordinateScale reads custom value', async () => {
const { mod, restore } = await loadCoordNorm({
MOBILE_MCP_COORDINATE_SCALE: '999',
});
try {
expect(mod.coordinateScale()).toBe(999);
} finally {
restore();
}
});
// ── Description rewriting tests ──
test("rewriteDescription replaces 'in pixels' with normalized wording", async () => {
const { mod, restore } = await loadCoordNorm();
try {
const result = mod.rewriteDescription(
'Click on the screen at x,y in pixels',
);
expect(result).toContain('0-1000 normalized coordinates');
expect(result).not.toContain('in pixels');
} finally {
restore();
}
});
test('coordParamDesc is passthrough when not normalized', async () => {
const { mod, restore } = await loadCoordNorm({
MOBILE_MCP_COORDINATE_SPACE: '0',
});
try {
const desc = 'The x coordinate, in pixels';
expect(mod.coordParamDesc(desc)).toBe(desc);
} finally {
restore();
}
});
test('coordParamDesc rewrites when normalized', async () => {
const { mod, restore } = await loadCoordNorm({
MOBILE_MCP_COORDINATE_SPACE: '1',
});
try {
const result = mod.coordParamDesc('The x coordinate, in pixels');
expect(result).toContain('0-1000 normalized');
} finally {
restore();
}
});

View file

@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { IosManager, IosRobot } from "../src/ios";
import { PNG } from "../src/png";
test.describe("ios", () => {
let robot: IosRobot;
let hasOneDevice = false;
test.beforeAll(async () => {
const manager = new IosManager();
const devices = await manager.listDevices();
hasOneDevice = devices.length === 1;
robot = new IosRobot(devices?.[0]?.deviceId || "");
});
test("should be able to get screenshot", async () => {
test.skip(!hasOneDevice, "requires exactly one ios device");
const screenshot = await robot.getScreenshot();
// an black screenshot (screen is off) still consumes over 30KB
expect(screenshot.length).toBeGreaterThan(128 * 1024);
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
const screenSize = await robot.getScreenSize();
// wda returns screen size as points, round up
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height);
});
});

View file

@ -0,0 +1,189 @@
import { test, expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { PNG } from "../src/png";
import { MobileDevice } from "../src/mobile-device";
import { Mobilecli } from "../src/mobilecli";
test.describe("iphone-simulator", () => {
const mobilecli = new Mobilecli();
const devicesResponse = mobilecli.getDevices({
platform: "ios",
type: "simulator",
includeOffline: false,
});
const bootedSimulators = devicesResponse.data.devices;
const hasOneSimulator = bootedSimulators.length >= 1;
const device = new MobileDevice(bootedSimulators?.[0]?.id || "");
const restartApp = async (app: string) => {
await device.launchApp(app);
await device.terminateApp(app);
await device.launchApp(app);
};
const restartPreferencesApp = async () => {
await restartApp("com.apple.Preferences");
};
const restartRemindersApp = async () => {
await restartApp("com.apple.reminders");
};
test("should be able to swipe", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await restartPreferencesApp();
// make sure "General" is present (since it's at the top of the list)
const elements1 = await device.getElementsOnScreen();
expect(elements1.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1);
// swipe up (bottom of screen to top of screen)
await device.swipe("up");
// make sure "General" is not visible now
const elements2 = await device.getElementsOnScreen();
expect(elements2.findIndex(e => e.name === "com.apple.settings.general")).toBe(-1);
// swipe down
await device.swipe("down");
// make sure "General" is visible again
const elements3 = await device.getElementsOnScreen();
expect(elements3.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1);
});
test("should be able to send keys and press enter", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await restartRemindersApp();
// find new reminder element
await new Promise(resolve => setTimeout(resolve, 3000));
const elements = await device.getElementsOnScreen();
const newElement = elements.find(e => e.label === "New Reminder");
expect(newElement, "should have found New Reminder element").toBeDefined();
// click on new reminder
await device.tap(newElement.rect.x, newElement.rect.y);
// wait for keyboard to appear
await new Promise(resolve => setTimeout(resolve, 1000));
// send keys with press button "Enter"
const random1 = randomBytes(8).toString("hex");
await device.sendKeys(random1);
await device.pressButton("ENTER");
// send keys with "\n"
const random2 = randomBytes(8).toString("hex");
await device.sendKeys(random2 + "\n");
const elements2 = await device.getElementsOnScreen();
expect(elements2.findIndex(e => e.value === random1)).not.toBe(-1);
expect(elements2.findIndex(e => e.value === random2)).not.toBe(-1);
});
test("should be able to get the screen size", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
const screenSize = await device.getScreenSize();
expect(screenSize.width).toBeGreaterThan(256);
expect(screenSize.height).toBeGreaterThan(256);
expect(screenSize.scale).toBeGreaterThanOrEqual(1);
expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3);
});
test("should be able to get screenshot", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
const screenshot = await device.getScreenshot();
expect(screenshot.length).toBeGreaterThan(64 * 1024);
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
const screenSize = await device.getScreenSize();
// wda returns screen size as points, round up
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height);
});
test("should be able to open url", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
// simply checking thato openurl with https:// launches safari
await device.openUrl("https://www.example.com");
await new Promise(resolve => setTimeout(resolve, 1000));
const elements = await device.getElementsOnScreen();
expect(elements.length).toBeGreaterThan(0);
const addressBar = elements.find(element => element.type === "TextField" && element.name === "TabBarItemTitle" && element.label === "Address");
expect(addressBar, "should have address bar").toBeDefined();
});
test("should be able to list apps", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
const apps = await device.listApps();
const packages = apps.map(app => app.packageName);
expect(packages).toContain("com.apple.mobilesafari");
expect(packages).toContain("com.apple.reminders");
expect(packages).toContain("com.apple.Preferences");
});
test("should be able to get elements on screen", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await device.pressButton("HOME");
await new Promise(resolve => setTimeout(resolve, 2000));
const elements = await device.getElementsOnScreen();
expect(elements.length).toBeGreaterThan(0);
// must have News app in home screen
const element = elements.find(e => e.type === "Icon" && e.label === "News");
expect(element, "should have News app in home screen").toBeDefined();
});
test("should be able to launch and terminate app", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await restartPreferencesApp();
await new Promise(resolve => setTimeout(resolve, 2000));
const elements = await device.getElementsOnScreen();
const buttons = elements.filter(e => e.type === "Button").map(e => e.label);
expect(buttons).toContain("General");
expect(buttons).toContain("Accessibility");
// make sure app is terminated
await device.terminateApp("com.apple.Preferences");
const elements2 = await device.getElementsOnScreen();
const buttons2 = elements2.filter(e => e.type === "Button").map(e => e.label);
expect(buttons2).not.toContain("General");
});
/*
test("should be able to get and set orientation", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
// Set to portrait and verify
await device.setOrientation("portrait");
const portrait = await device.getOrientation();
expect(portrait).toBe("portrait");
// Set to landscape and verify
await device.setOrientation("landscape");
const landscape = await device.getOrientation();
expect(landscape).toBe("landscape");
// Return to portrait
await device.setOrientation("portrait");
const portraitAgain = await device.getOrientation();
expect(portraitAgain).toBe("portrait");
});
*/
test("should throw an error if button is not supported", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await expect(device.pressButton("NOT_A_BUTTON" as any)).rejects.toThrow("unsupported button: NOT_A_BUTTON");
});
});

View file

@ -0,0 +1,119 @@
import { test, expect } from "@playwright/test";
import { Mobilecli } from "../src/mobilecli";
type ExecuteCommandCall = {
args: string[];
};
function createMockMobilecli(mockResponse: string): { mobilecli: Mobilecli; calls: ExecuteCommandCall[] } {
const mobilecli = new Mobilecli();
const calls: ExecuteCommandCall[] = [];
mobilecli.executeCommand = function(args: string[]): string {
calls.push({ args });
return mockResponse;
};
return { mobilecli, calls };
}
test.describe("mobilecli", () => {
const mobilecli = new Mobilecli();
test.describe("getVersion", () => {
test("should return a version string", () => {
const version = mobilecli.getVersion();
expect(version.length).toBeGreaterThan(0);
expect(version).not.toContain("failed");
});
test("should return version in correct format", () => {
const version = mobilecli.getVersion();
// Version should be in format like "0.0.45" or similar
const versionPattern = /^\d+\.\d+\.\d+/;
expect(version, `Version "${version}" should match pattern X.Y.Z`).toMatch(versionPattern);
});
test("should return failed when MOBILECLI_PATH points to invalid location", () => {
try {
process.env.MOBILECLI_PATH = "/tmp";
const mobilecli = new Mobilecli();
const version = mobilecli.getVersion();
expect(version, `Expected version to include "failed" but got: ${version}`).toContain("failed");
} finally {
delete process.env.MOBILECLI_PATH;
}
});
test("should call executeCommand with --version argument", () => {
const { mobilecli, calls } = createMockMobilecli("mobilecli version 1.0.0");
const version = mobilecli.getVersion();
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["--version"]);
expect(version).toBe("1.0.0");
});
});
test.describe("getDevices", () => {
const mockDevicesResponse = JSON.stringify({
status: "ok",
data: {
devices: [
{
id: "device1",
name: "Test Device",
platform: "ios",
type: "simulator",
version: "17.0"
}
]
}
});
test("should call executeCommand with devices argument when no options", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices();
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices"]);
});
test("should call executeCommand with platform filter", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ platform: "ios" });
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--platform", "ios"]);
});
test("should call executeCommand with type filter", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ type: "simulator" });
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--type", "simulator"]);
});
test("should call executeCommand with includeOffline flag", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ includeOffline: true });
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--include-offline"]);
});
test("should call executeCommand with combined options", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({
platform: "android",
type: "emulator",
includeOffline: true
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--include-offline", "--platform", "android", "--type", "emulator"]);
});
});
});

View file

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
import { PNG } from "../src/png";
test.describe("png", () => {
test("should be able to parse png", () => {
const buffer = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=";
const png = new PNG(Buffer.from(buffer, "base64"));
expect(png.getDimensions().width).toBe(1);
expect(png.getDimensions().height).toBe(1);
});
test("should be able to detect an invalid png", () => {
const buffer = btoa("IAMADUCKIAMADUCKIAMADUCKIAMADUCKIAMADUCK");
const png = new PNG(Buffer.from(buffer, "base64"));
expect(() => png.getDimensions()).toThrow();
});
});

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "node",
"strict": true,
"module": "CommonJS",
"outDir": "./lib"
},
"include": [
"src",
],
}