mobai-mcp 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,7 +140,7 @@ Pass this as the `commands` argument (a JSON string) to `execute_dsl` along with
140
140
 
141
141
  ## Troubleshooting
142
142
 
143
- **"Connection refused"** — Make sure the MobAI desktop app is running and the API is reachable at `http://127.0.0.1:8686`.
143
+ **"Connection refused" / "Could not reach the MobAI desktop app"** — Make sure the MobAI desktop app is installed and running, and the API is reachable at `http://127.0.0.1:8686`. If you don't have it yet, download and install it from [https://mobai.run/download](https://mobai.run/download).
144
144
 
145
145
  **"Bridge not running"** — Call `start_bridge` first. The iOS bridge can take up to a minute to come up.
146
146
 
package/dist/index.js CHANGED
@@ -15,6 +15,28 @@ import { RESOURCES, getResourceContent } from "./resources.js";
15
15
  const API_BASE_URL = "http://127.0.0.1:8686/api/v1";
16
16
  const DEFAULT_TIMEOUT_MS = 300000; // 5 minutes (matches Go httpClient timeout)
17
17
  const SCREENSHOT_DIR = path.join(os.tmpdir(), "mobai", "screenshots");
18
+ const DOWNLOAD_URL = "https://mobai.run/download";
19
+ // Message shown when the MobAI desktop app is not reachable at its local API.
20
+ const APP_NOT_RUNNING_MESSAGE = `Could not reach the MobAI desktop app at 127.0.0.1:8686. ` +
21
+ `Make sure the MobAI desktop app is installed and running, then try again. ` +
22
+ `If you don't have it yet, download and install it from ${DOWNLOAD_URL}.`;
23
+ /**
24
+ * Detects the "connection refused" / "could not connect" family of errors that
25
+ * Node's fetch throws when nothing is listening on the MobAI API port. These
26
+ * surface as a TypeError ("fetch failed") whose `cause` carries an errno code
27
+ * such as ECONNREFUSED / ENOTFOUND / ECONNRESET.
28
+ */
29
+ function isConnectionError(err) {
30
+ if (!(err instanceof Error))
31
+ return false;
32
+ const codes = ["ECONNREFUSED", "ENOTFOUND", "ECONNRESET", "EHOSTUNREACH", "ETIMEDOUT"];
33
+ const cause = err.cause;
34
+ const causeCode = cause && typeof cause === "object" ? cause.code : undefined;
35
+ if (typeof causeCode === "string" && codes.includes(causeCode))
36
+ return true;
37
+ // Fallback: undici reports a bare "fetch failed" TypeError for these.
38
+ return err.name === "TypeError" && /fetch failed/i.test(err.message);
39
+ }
18
40
  // ---------------------------------------------------------------------------
19
41
  // Screenshot helpers
20
42
  // ---------------------------------------------------------------------------
@@ -55,7 +77,16 @@ async function doRequest(method, urlPath, payload, timeoutMs = DEFAULT_TIMEOUT_M
55
77
  if (payload !== undefined && ["POST", "PUT", "PATCH"].includes(method)) {
56
78
  opts.body = typeof payload === "string" ? payload : JSON.stringify(payload);
57
79
  }
58
- const response = await fetch(url, opts);
80
+ let response;
81
+ try {
82
+ response = await fetch(url, opts);
83
+ }
84
+ catch (err) {
85
+ if (isConnectionError(err)) {
86
+ throw new Error(APP_NOT_RUNNING_MESSAGE);
87
+ }
88
+ throw err;
89
+ }
59
90
  clearTimeout(timeoutId);
60
91
  const text = await response.text();
61
92
  let body;
@@ -192,6 +223,12 @@ const TOOLS = [
192
223
  properties: {
193
224
  device_id: { type: "string", description: "Device ID" },
194
225
  path: { type: "string", description: "Local file path to the app (.apk or .ipa)" },
226
+ resign: { type: "boolean", description: "Sign the IPA with iloader before installing (iOS only)" },
227
+ apple_id: { type: "string", description: "Apple ID for signing (optional; used when resign=true)" },
228
+ password: { type: "string", description: "Apple ID password for signing (optional; uses cached credentials if empty)" },
229
+ profile_path: { type: "string", description: "Provisioning profile to reuse for an offline re-sign (with cert_path/key_path); falls back to online signing if invalid" },
230
+ cert_path: { type: "string", description: "Signing certificate PEM to reuse for an offline re-sign" },
231
+ key_path: { type: "string", description: "Signing private key PEM to reuse for an offline re-sign" },
195
232
  },
196
233
  required: ["device_id", "path"],
197
234
  },
@@ -211,16 +248,62 @@ const TOOLS = [
211
248
  // DSL execution
212
249
  {
213
250
  name: "execute_dsl",
214
- description: `Execute a batch of DSL commands on a device. This is the primary tool for all device interaction — tap, type, swipe, observe, launch apps, assertions, web automation, and more.
251
+ description: `The example below is the full command surface. For richer semantics - per-action defaults, platform notes, retry/failure strategies, observe and scroll guidance, web/OCR caveats - read the MCP resource mobai://reference/device-automation. Read it the first time you hit anything the example doesn't make obvious.
252
+
253
+ Execute a batch of DSL commands on a device. This is the primary tool for all device interaction - tap, type, swipe, observe, launch apps, assertions, web automation, and more.
254
+
255
+ Input: JSON string with "version": "0.2" and a "steps" array. Optional script-wide "params" (declared defaults, substituted as \${name}) and "on_fail" (strategy: abort|skip|retry|replan|require_user; retry also takes max_retries, retry_delay_ms, fallback_strategy). Any step may carry its own "on_fail".
215
256
 
216
- You MUST read the MCP resource mobai://reference/device-automation to learn how to control devices before using this tool.
257
+ Every action below appears at least once - this is the full command surface (semantics, defaults, and platform notes are in mobai://reference/device-automation):
258
+ {"version":"0.2","params":{"query":"shoes"},"on_fail":{"strategy":"retry","max_retries":2,"retry_delay_ms":1000,"fallback_strategy":{"strategy":"skip"}},"steps":[
259
+ {"action":"open_app","bundle_id":"com.apple.Preferences","fresh":true,"debug":false},
260
+ {"action":"kill_app","bundle_id":"com.apple.mobilesafari"},
261
+ {"action":"open_link","url":"myapp://profile/42"},
262
+ {"action":"tap","predicate":{"text_contains":"Wi-Fi","type":"button"}},
263
+ {"action":"tap","coords":{"x":200,"y":640}},
264
+ {"action":"double_tap","predicate":{"text":"Photo"}},
265
+ {"action":"two_finger_tap","predicate":{"text":"Map"}},
266
+ {"action":"long_press","predicate":{"text":"Item"},"duration_ms":1000},
267
+ {"action":"pinch","predicate":{"text_contains":"Map"},"scale":0.5,"velocity":1.0},
268
+ {"action":"type","text":"\${query}","predicate":{"type":"input"},"clear_first":true,"dismiss_keyboard":true},
269
+ {"action":"type_secret","secret_id":"test-account-password","predicate":{"type":"input","text_contains":"Password"}},
270
+ {"action":"clear","predicate":{"type":"input"}},
271
+ {"action":"toggle","predicate":{"type":"switch","text_contains":"Wi-Fi"},"state":"on"},
272
+ {"action":"press_key","key":"enter"},
273
+ {"action":"swipe","direction":"up","distance":"medium"},
274
+ {"action":"swipe","from_coords":{"x":200,"y":600},"to_coords":{"x":200,"y":200},"duration_ms":300},
275
+ {"action":"scroll","direction":"down","to_element":{"predicate":{"text":"Privacy"}},"predicate":{"type":"scrollview"},"max_scrolls":10,"amount":"page"},
276
+ {"action":"drag","from":{"predicate":{"text":"Item"}},"to_element":{"predicate":{"text":"Trash"}},"press_duration_ms":500,"hold_duration_ms":200,"duration_ms":500},
277
+ {"action":"drag","from_coords":{"x":100,"y":400},"to_coords":{"x":300,"y":400}},
278
+ {"action":"drag_path","points":[{"x":100,"y":400,"duration_ms":200},{"x":150,"y":300,"duration_ms":150},{"x":300,"y":500,"duration_ms":300}]},
279
+ {"action":"navigate","target":"home"},
280
+ {"action":"set_location","lat":40.7128,"lon":-74.0060},
281
+ {"action":"reset_location"},
282
+ {"action":"siri","prompt":"Search YouTube for cat videos"},
283
+ {"action":"if_exists","predicate":{"text":"Allow"},"then":[{"action":"tap","predicate":{"text":"Allow"}}],"else":[{"action":"tap","predicate":{"text":"Don't Allow"}}]},
284
+ {"action":"delay","duration_ms":1000},
285
+ {"action":"wait_for","predicate":{"text":"Welcome"},"timeout_ms":5000,"poll_interval_ms":500},
286
+ {"action":"assert_exists","predicate":{"text":"Success"},"timeout_ms":3000},
287
+ {"action":"assert_not_exists","predicate":{"text":"Error"}},
288
+ {"action":"assert_count","predicate":{"type":"cell"},"count":5},
289
+ {"action":"assert_screen_changed","threshold_percent":15},
290
+ {"action":"metrics_start","types":["system_cpu","fps"],"interval_ms":1000,"label":"login_flow","capture_logs":true,"thresholds":{"cpu_high":80,"fps_low":45}},
291
+ {"action":"metrics_stop","format":"summary"},
292
+ {"action":"record_start","file_path":"/tmp/mobai/rec"},
293
+ {"action":"record_stop"},
294
+ {"action":"select_web_context","url_contains":"google.com"},
295
+ {"action":"navigate","context":"web","url":"https://example.com"},
296
+ {"action":"tap","context":"web","predicate":{"css_selector":"button.submit"}},
297
+ {"action":"type","context":"web","predicate":{"css_selector":"input#email"},"text":"user@example.com","clear_first":true},
298
+ {"action":"execute_js","context":"web","script":"return document.title","async":false},
299
+ {"action":"screenshot","file_path":"/tmp/mobai","name":"final_state"},
300
+ {"action":"wait_for","stable":true,"timeout_ms":3000},
301
+ {"action":"observe","include":["ui_tree"],"only_visible":true,"filter":{"text_regex":"Settings|Wi-Fi"},"store_as":"end_state"}
302
+ ]}
217
303
 
218
- Input: JSON string with "version": "0.2" and "steps" array. Example:
219
- {"version":"0.2","steps":[
220
- {"action":"open_app","bundle_id":"com.apple.Preferences"},
221
- {"action":"tap","predicate":{"text_contains":"Wi-Fi"}},
222
- {"action":"wait_for","predicate":{"type":"switch"},"timeout_ms":3000}
223
- ]}`,
304
+ Predicate - object passed in a step's "predicate"/"from"/"to_element". Combine fields (AND); prefer text_contains over exact text. All native fields:
305
+ {"text":"exact","text_contains":"substr","text_starts_with":"prefix","text_regex":"\\d+ items","value":"typed value","value_contains":"typed substr","type":"button|input|switch|text|image|cell|scrollview","accessibility_id":"login_btn","enabled":true,"visible":true,"selected":false,"index":0,"bounds_hint":"top_half|bottom_half|left_half|right_half|center","near":{"text_contains":"Email","direction":"below|above|left|right|any","max_distance":100},"parent_of":{"text":"child label"}}
306
+ Web predicate (context:"web") uses instead: {"css_selector":"button.submit"}.`,
224
307
  inputSchema: {
225
308
  type: "object",
226
309
  properties: {
@@ -406,8 +489,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
406
489
  }
407
490
  case "list_apps":
408
491
  return textResult(await doGet(`/devices/${args?.device_id}/apps`));
409
- case "install_app":
410
- return textResult(await doPost(`/devices/${args?.device_id}/install-app`, { path: args?.path }));
492
+ case "install_app": {
493
+ const body = { path: args?.path };
494
+ if (args?.resign) {
495
+ body.resign = true;
496
+ if (args?.apple_id)
497
+ body.appleId = args.apple_id;
498
+ if (args?.password)
499
+ body.password = args.password;
500
+ if (args?.profile_path)
501
+ body.profilePath = args.profile_path;
502
+ if (args?.cert_path)
503
+ body.certPath = args.cert_path;
504
+ if (args?.key_path)
505
+ body.keyPath = args.key_path;
506
+ }
507
+ return textResult(await doPost(`/devices/${args?.device_id}/install-app`, body));
508
+ }
411
509
  case "uninstall_app":
412
510
  return textResult(await doDelete(`/devices/${args?.device_id}/apps/${encodeURIComponent(args?.bundle_id)}`));
413
511
  // DSL execution
package/dist/resources.js CHANGED
@@ -123,7 +123,7 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
123
123
  <screenshot-tools>
124
124
  get_screenshot — fast low-quality image for LLM visual analysis.
125
125
  save_screenshot — full-quality PNG for reporting, debugging, or sharing.
126
- To verify animations and UI transitions, use record_start/record_stop.
126
+ A screenshot is a single settled frame — it cannot capture motion. Anything transient (animations, transitions, loading spinners; a screen transition is often only ~300ms) will be missed or caught mid-frame. To verify transitional behavior, use record_start/record_stop, which samples continuously and flags suspicious frames.
127
127
  </screenshot-tools>
128
128
 
129
129
  <infinite-scrolling>To collect data from infinite-scrolling views (feeds, search results), scroll to load a batch first, then observe with only_visible:false to get all loaded items in one go.</infinite-scrolling>
@@ -132,6 +132,7 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
132
132
  Element not visible — use scroll with to_element to find it.
133
133
  App launches and page transitions take time — use wait_for or delay.
134
134
  Observe before acting on unfamiliar screens.
135
+ NO_MATCH / failed assert_exists: if the element exists off-screen, the error lists it under "candidates" — scroll to bring it into view (off-screen elements cannot be tapped). Empty candidates means it is genuinely absent or not yet rendered.
135
136
  </troubleshooting>
136
137
  </guide>
137
138
 
@@ -145,6 +146,8 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
145
146
  <field name="text_contains" type="string">Substring, case-insensitive — preferred for most matching</field>
146
147
  <field name="text_starts_with" type="string">Prefix match</field>
147
148
  <field name="text_regex" type="string">Regex pattern — use for dynamic text (numbers, dates, counts)</field>
149
+ <field name="value" type="string">Exact match on the element's entered/current value (not its label/placeholder). Use to verify what was typed into a field — text matching sees the placeholder, value sees the content. Shown as content="..." in the UI tree. Secure fields are masked, so only length/non-empty is meaningful.</field>
150
+ <field name="value_contains" type="string">Substring match (case-insensitive) on the entered/current value</field>
148
151
  <field name="type" type="string">button, input, switch, text, image, cell, scrollview</field>
149
152
  <field name="accessibility_id" type="string">Exact match on the #id shown in UI tree (without the # prefix)</field>
150
153
  <field name="enabled" type="bool">Enabled state</field>
@@ -158,7 +161,6 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
158
161
 
159
162
  <predicate context="web">
160
163
  <field name="css_selector" type="string">CSS selector</field>
161
- <field name="xpath" type="string">XPath expression</field>
162
164
  </predicate>
163
165
 
164
166
  <failure-strategy>
@@ -173,8 +175,11 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
173
175
 
174
176
  <action name="open_app">
175
177
  <field name="bundle_id" required="yes"/>
178
+ <field name="fresh" type="bool">Kill the app before launching to ensure a clean start from the home screen. Use when the app may have been left on an arbitrary screen from a previous run.</field>
179
+ <field name="debug" type="bool">ONLY for debug-built apps (e.g. Flutter dev builds, Xcode debug builds) that need a debugger attached to run. Attaches debugserver, streams stdout/stderr to a log file; result has log_path. Do NOT use for release/App Store apps — they launch fine with debug: false.</field>
176
180
  <example>{"action": "open_app", "bundle_id": "com.apple.Preferences"}</example>
177
- <note>If open_app fails or the app disappears immediately after launch, the app has likely crashed. Do NOT retry or try alternative launch methods — start crash investigation instead. Use metrics_start with capture_logs: true to capture device logs, then diagnose.</note>
181
+ <example>{"action": "open_app", "bundle_id": "com.apple.Preferences", "fresh": true}</example>
182
+ <note>If open_app fails or the app disappears immediately after launch, the app has likely crashed. Do NOT retry or try alternative launch methods — start crash investigation instead. Use debug: true (or metrics_start with capture_logs: true) to capture device logs, then diagnose.</note>
178
183
  </action>
179
184
 
180
185
  <action name="tap">
@@ -211,6 +216,21 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
211
216
  <example>{"action": "type", "text": "Hello", "predicate": {"type": "input"}, "clear_first": true}</example>
212
217
  </action>
213
218
 
219
+ <action name="type_secret">
220
+ Type a stored credential without ever exposing its value. secret_id names a credential saved in the app; its value is resolved from the keychain at run time and typed on-device - it never appears in the script, logs, results, or the model context. Use for passwords/tokens in login steps. Same field-targeting as type.
221
+ <field name="secret_id" required="yes">Id of a stored credential (see list_secrets)</field>
222
+ <field name="predicate">Optional target field to focus first</field>
223
+ <field name="clear_first" type="bool"/>
224
+ <field name="dismiss_keyboard" type="bool" default="false"/>
225
+ <example>{"action": "type_secret", "secret_id": "test-account-password", "predicate": {"type": "input", "text_contains": "Password"}}</example>
226
+ </action>
227
+
228
+ <action name="clear">
229
+ Clear a field's text without typing. With a predicate, focuses that field first; without one, clears the currently focused field.
230
+ <field name="predicate">Optional target element</field>
231
+ <example>{"action": "clear", "predicate": {"type": "input"}}</example>
232
+ </action>
233
+
214
234
  <action name="swipe">
215
235
  Direction = finger movement. Use direction OR from_coords/to_coords.
216
236
  <field name="direction">up, down, left, right</field>
@@ -223,11 +243,13 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
223
243
 
224
244
  <action name="scroll">
225
245
  Direction = semantic (where to look), not finger movement.
226
- <field name="direction" required="yes">down (look below), up (look above)</field>
246
+ <field name="direction" required="yes">down (look below), up (look above), left (look left), right (look right). All four directions are supported - use left/right for horizontal scrollviews (carousels, palettes, paging) instead of falling back to raw-coordinate swipe.</field>
247
+ <field name="predicate" type="Predicate">Targets a specific scrollable container so the gesture lands inside it (e.g. {"type": "scrollview"} or {"parent_of": {"text": "Work"}}). Without it the gesture is applied at screen center - set this when multiple scrollviews share the screen (e.g. a horizontal palette above a vertical list) to avoid scrolling the wrong one.</field>
227
248
  <field name="to_element" type="TargetElement"/>
228
249
  <field name="max_scrolls" type="int" default="10"/>
229
250
  <field name="amount">small, page, full</field>
230
251
  <example>{"action": "scroll", "direction": "down", "to_element": {"predicate": {"text": "Privacy"}}, "max_scrolls": 10}</example>
252
+ <example>{"action": "scroll", "direction": "right", "predicate": {"type": "scrollview"}}</example>
231
253
  <note>scroll with to_element returns "reached end of scrollable content" if the list ends before the element is found. If it returns "element not found after scrolling" instead, the list has more content — increase max_scrolls or call scroll again to continue searching.</note>
232
254
  </action>
233
255
 
@@ -243,6 +265,12 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
243
265
  <example>{"action": "drag", "from": {"predicate": {"text": "App"}}, "to_element": {"predicate": {"text": "Folder"}}, "press_duration_ms": 500, "hold_duration_ms": 200}</example>
244
266
  </action>
245
267
 
268
+ <action name="drag_path">
269
+ <field name="points" type="array of {x, y, duration_ms}" required="true">Single-finger drag along a multi-point path. Each point's duration_ms is the time to move to it from the previous point. The first point is the touch-down location and its duration_ms is an optional initial press-hold (omit or 0 for none). Needs at least 2 points; every point after the first must have duration_ms > 0. Use this (not drag) for swipe-path gestures like unlock patterns, freeform draws, or curved scrolls.</field>
270
+ <example>{"action": "drag_path", "points": [{"x": 100, "y": 400}, {"x": 150, "y": 300, "duration_ms": 150}, {"x": 300, "y": 500, "duration_ms": 300}]}</example>
271
+ <example>{"action": "drag_path", "points": [{"x": 100, "y": 400, "duration_ms": 200}, {"x": 300, "y": 400, "duration_ms": 250}]}</example>
272
+ </action>
273
+
246
274
  <action name="press_key">
247
275
  <field name="key" required="yes"/>
248
276
  <platform name="android">enter, tab, delete, escape, volume_up, volume_down, home, back, recent_apps, mute, power, play_pause, next, previous</platform>
@@ -319,8 +347,15 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
319
347
  <example>{"action": "reset_location"}</example>
320
348
  </action>
321
349
 
350
+ <action name="open_link">
351
+ Opens a URL or deep link on the device. The OS routes custom schemes (myapp://path) to the registered app, so this jumps straight to a screen without navigating the UI. Use for deep-link entry points instead of tapping through the app.
352
+ <field name="url" required="yes">Full URL or deep link, e.g. https://example.com or myapp://profile/42</field>
353
+ <example>{"action": "open_link", "url": "myapp://profile/42"}</example>
354
+ <example>{"action": "open_link", "url": "https://example.com"}</example>
355
+ </action>
356
+
322
357
  <action name="siri">
323
- iOS only. Sends a voice command to Siri via XCUISiriService. Auto-approves consent dialogs, captures Siri's response text, then dismisses the Siri UI.
358
+ iOS only. Sends a voice command to Siri service on iOS devices. Auto-approves consent dialogs, captures Siri's response text, then dismisses the Siri UI.
324
359
  Use for triggering SiriKit intents and App Shortcuts registered by apps (media playback, messaging, banking shortcuts, etc.).
325
360
  The captured response is stored in "siri_response" and returned in the step result. If Siri asks a follow-up question, reformulate the prompt with more detail and call siri again.
326
361
  <field name="prompt" required="yes">Voice command text</field>
@@ -342,7 +377,7 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
342
377
  </action>
343
378
 
344
379
  <action name="tap/type/press_key/wait_for/if_exists">
345
- Same fields as native but use css_selector or xpath in predicate.
380
+ Same fields as native but use css_selector in predicate.
346
381
  <example>{"action": "tap", "context": "web", "predicate": {"css_selector": "button.submit"}}</example>
347
382
  <example>{"action": "type", "context": "web", "predicate": {"css_selector": "input#email"}, "text": "user@example.com", "clear_first": true}</example>
348
383
  Web press_key keys: enter, tab, delete, escape.
@@ -383,6 +418,15 @@ const DEVICE_AUTOMATION_REF = `<device-automation-reference>
383
418
  <example>{"action": "assert_screen_changed", "threshold_percent": 15}</example>
384
419
  <note>Pattern: observe(screenshot) then action then delay then assert_screen_changed. Do NOT observe after the action — it resets the baseline.</note>
385
420
  </action>
421
+
422
+ <action name="ai_assert">
423
+ <field name="assert_prompt" required="yes"/>
424
+ <field name="include" type="[]string" note="opt-in extra context: screenshot, ocr (iOS). UI tree + the source script are always included."/>
425
+ <field name="timeout_ms" type="int" note="bounds the verdict (LLM/CLI reply), excluding context gathering. Default 60000."/>
426
+ <field name="message" note="prefixes the failure reason"/>
427
+ <example>{"action": "ai_assert", "assert_prompt": "the reply answers the user's question and is not an error", "include": ["screenshot"]}</example>
428
+ <note>Judges a natural-language assertion with the user's configured agent — either an LLM API provider (direct call) or Claude Code (spawned, reports back via report_assertion). Use for non-deterministic content (AI/LLM output, dynamic feeds) where exact-match assertions don't work. Treat as a soft assertion — it is non-deterministic.</note>
429
+ </action>
386
430
  </assertions>
387
431
 
388
432
  <metrics>
@@ -435,7 +479,7 @@ const TESTING_REF = `<testing-reference>
435
479
 
436
480
  <rules>
437
481
  <rule>Never ask the user for information you can get yourself — use observe, list_apps, get_ui_tree.</rule>
438
- <rule>Always add wait_for before every element interaction (tap, type, toggle, long_press, double_tap, drag). Exception: the element was asserted on the immediately preceding line.</rule>
482
+ <rule>Add wait_for before an interaction only when the PREVIOUS action may have changed the screen and the target might not be ready yet - after opening an app, navigating, a screen transition, opening a modal/sheet, scrolling, or submitting something that reloads. If the previous action left the same screen up (typing into a field, toggling a switch, tapping a control that stays put) the element is already there - skip wait_for. Never add it before the element you just asserted.</rule>
439
483
  <rule>Always use predicates over coordinates — predicates survive layout changes.</rule>
440
484
  <rule>Always prefer UI tree and OCR over screenshots for element discovery.</rule>
441
485
  <rule>Your first action must be observe — never ask the user first.</rule>
@@ -495,6 +539,7 @@ const TESTING_REF = `<testing-reference>
495
539
 
496
540
  <actions>
497
541
  app "com.example.app" — launch app
542
+ app "com.example.app" fresh — kill + launch for clean state
498
543
  kill_app "com.example.app" — force-close app
499
544
  tap "Text" — tap by text
500
545
  tap "Field" near "Label" — tap near another element
@@ -514,6 +559,8 @@ const TESTING_REF = `<testing-reference>
514
559
  type ~"Field" → "text" — partial match field
515
560
  type "Field" → "text" clear — clear first
516
561
  type "Field" → "text" keep_keyboard — keep keyboard open
562
+ type_secret "Field" → "secret-id" - type a stored credential (value never in script/logs)
563
+ type_secret "secret-id" - type credential into focused field
517
564
  swipe up|down|left|right — swipe direction
518
565
  swipe up distance:long — short/medium/long
519
566
  swipe up duration:500 — custom duration (ms)
@@ -521,11 +568,13 @@ const TESTING_REF = `<testing-reference>
521
568
  scroll down — scroll
522
569
  scroll down to "Element" — scroll until visible
523
570
  scroll down to "Footer" max_scrolls:10 — limit attempts
571
+ scroll right in type:scrollview - target a specific container
524
572
  toggle "Wi-Fi" on|off — toggle switch
525
573
  toggle type:switch near "Wi-Fi" on — modifier-only
526
574
  drag "Item" to "Trash" — drag element
527
575
  drag 100,200 to 300,400 duration:500 — coordinate drag
528
576
  drag "App" to "Folder" press_duration:500 hold_duration:200 — press-hold-move-hold-release
577
+ drag_path 100,400 150,300:150 300,500:300 - multi-point path (X,Y:moveMs, first point's :ms = optional press-hold)
529
578
  wait_for "Element" timeout:5000 — wait for element
530
579
  wait_for type:button bounds:bottom_half timeout:3000 — modifier-only
531
580
  delay 1000 — wait N ms
@@ -539,6 +588,7 @@ const TESTING_REF = `<testing-reference>
539
588
  paste_text "Field" — paste clipboard into element
540
589
  set_location 40.7128,-74.0060 — simulate GPS location (lat,lon)
541
590
  reset_location — stop location simulation
591
+ open_link "myapp://profile/42" - open a URL or deep link (routes to the app)
542
592
  siri "Search YouTube for cats" — invoke Siri with voice command (iOS only)
543
593
  observe — observe screen
544
594
  screenshot "path.png" — take screenshot
@@ -548,6 +598,8 @@ const TESTING_REF = `<testing-reference>
548
598
  assert_exists "Element" — element is on screen
549
599
  assert_not_exists "Element" — element is NOT on screen
550
600
  assert_exists "Header" bounds:top_right — with region filter
601
+ assert_exists value:"hello" — assert a field's entered value (exact); sees typed content, not placeholder
602
+ assert_exists value_contains:"@mail" — assert a substring of the entered value
551
603
  assert_count "Cell" expected:5 — element count
552
604
  checkpoint "name" — mark checkpoint
553
605
  </assertions>
@@ -620,9 +672,10 @@ const TESTING_REF = `<testing-reference>
620
672
  When the user asks to create an API from a mobile app flow:
621
673
  1. Observe the app and understand the flow
622
674
  2. Write a .mob script with # Param: declarations for inputs and extract actions for outputs
623
- 3. Save it to {MOBAI_DATA_DIR}/apis/{name}.mobflat (gmail-send.mob) or nested (gmail/send.mob)
624
- 4. Test it with test_run using project_dir: {MOBAI_DATA_DIR}/apis/ and case_path: {name}.mob
625
- 5. List available APIs: GET /api/v1/apis
675
+ 3. Use app "bundle.id" fresh to ensure a clean start the app may be left on any screen from a previous call
676
+ 4. Save it to {MOBAI_DATA_DIR}/apis/{name}.mob — flat (gmail-send.mob) or nested (gmail/send.mob)
677
+ 5. Test it with test_run using project_dir: {MOBAI_DATA_DIR}/apis/ and case_path: {name}.mob
678
+ 6. List available APIs: GET /api/v1/apis
626
679
  Call an API: POST /api/v1/apis/run/{name} with {"device_id": "...", "params": {...}}
627
680
  The {name} segment is the path inside apis/ minus the .mob extension.
628
681
  API runs do not persist results to .mobai/runs/ — only the extracted values come back in the response.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobai-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "mcpName": "io.github.MobAI-App/mobai-mcp",
5
5
  "description": "MCP server for MobAI - AI-powered mobile device automation",
6
6
  "type": "module",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/MobAI-App/mobai-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.3.0",
9
+ "version": "2.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "mobai-mcp",
14
- "version": "2.3.0",
14
+ "version": "2.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }