mobile-debug-mcp 0.12.8 → 0.14.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
@@ -1,4 +1,4 @@
1
- # Mobile Dev Tools
1
+ # Mobile Debug Tools
2
2
 
3
3
  A minimal, secure MCP server for AI-assisted mobile development. Build, install, and inspect Android/iOS apps from an MCP-compatible client.
4
4
 
@@ -33,7 +33,7 @@ I have a crash on the app, can you diagnose it, fix and validate using the mcp t
33
33
 
34
34
  ## Docs
35
35
 
36
- - Tools: [Tools](docs/TOOLS.md) — full input/response examples
36
+ - Tools: [Tools](docs/tools/TOOLS.md) — full input/response examples
37
37
  - Changelog: [Changelog](docs/CHANGELOG.md)
38
38
 
39
39
  ## License
@@ -1,5 +1,6 @@
1
1
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
2
2
  import { AndroidObserve } from "./observe.js";
3
+ import { scrollToElementShared } from "../tools/scroll_to_element.js";
3
4
  export class AndroidInteract {
4
5
  observe = new AndroidObserve();
5
6
  async waitForElement(text, timeout, deviceId) {
@@ -76,4 +77,15 @@ export class AndroidInteract {
76
77
  return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) };
77
78
  }
78
79
  }
80
+ async scrollToElement(selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId) {
81
+ return await scrollToElementShared({
82
+ selector,
83
+ direction,
84
+ maxScrolls,
85
+ scrollAmount,
86
+ deviceId,
87
+ fetchTree: async () => await this.observe.getUITree(deviceId),
88
+ swipe: async (x1, y1, x2, y2, duration, devId) => await this.swipe(x1, y1, x2, y2, duration, devId)
89
+ });
90
+ }
79
91
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "child_process";
2
2
  import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js";
3
3
  import { iOSObserve } from "./observe.js";
4
+ import { scrollToElementShared } from "../tools/scroll_to_element.js";
4
5
  export class iOSInteract {
5
6
  observe = new iOSObserve();
6
7
  async waitForElement(text, timeout, deviceId = "booted") {
@@ -66,4 +67,54 @@ export class iOSInteract {
66
67
  return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
67
68
  }
68
69
  }
70
+ async swipe(x1, y1, x2, y2, duration, deviceId = "booted") {
71
+ const device = await getIOSDeviceMetadata(deviceId);
72
+ // Use shared helper to detect idb
73
+ const idbExists = await isIDBInstalled();
74
+ if (!idbExists) {
75
+ return {
76
+ device,
77
+ success: false,
78
+ start: [x1, y1],
79
+ end: [x2, y2],
80
+ duration,
81
+ error: "iOS swipe requires 'idb' (iOS Device Bridge)."
82
+ };
83
+ }
84
+ try {
85
+ const targetUdid = (device.id && device.id !== 'booted') ? device.id : undefined;
86
+ // idb 'ui swipe' does not accept a duration parameter; use coordinates only
87
+ const args = ['ui', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString()];
88
+ if (targetUdid) {
89
+ args.push('--udid', targetUdid);
90
+ }
91
+ await new Promise((resolve, reject) => {
92
+ const proc = spawn(getIdbCmd(), args);
93
+ let stderr = '';
94
+ proc.stderr.on('data', d => stderr += d.toString());
95
+ proc.on('close', code => {
96
+ if (code === 0)
97
+ resolve();
98
+ else
99
+ reject(new Error(`idb ui swipe failed: ${stderr}`));
100
+ });
101
+ proc.on('error', err => reject(err));
102
+ });
103
+ return { device, success: true, start: [x1, y1], end: [x2, y2], duration };
104
+ }
105
+ catch (e) {
106
+ return { device, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
107
+ }
108
+ }
109
+ async scrollToElement(selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId = 'booted') {
110
+ return await scrollToElementShared({
111
+ selector,
112
+ direction,
113
+ maxScrolls,
114
+ scrollAmount,
115
+ deviceId,
116
+ fetchTree: async () => await this.observe.getUITree(deviceId),
117
+ swipe: async (x1, y1, x2, y2, duration, devId) => await this.swipe(x1, y1, x2, y2, duration, devId)
118
+ });
119
+ }
69
120
  }
@@ -291,117 +291,16 @@ export class iOSManage {
291
291
  }
292
292
  async startApp(bundleId, deviceId = "booted") {
293
293
  validateBundleId(bundleId);
294
- // Prepare instrumentation object upfront so it can be returned to callers
295
- const instrumentation = { ts: new Date().toISOString(), action: 'startApp', cmd: 'xcrun', args: ['simctl', 'launch', deviceId, bundleId], cwd: process.cwd(), env: { PATH: process.env.PATH, XCRUN_PATH: process.env.XCRUN_PATH } };
296
294
  try {
297
- // Instrumentation: persist and emit to stderr for server logs
298
- try {
299
- await fs.appendFile('/tmp/mcp_startapp_instrument.log', JSON.stringify(instrumentation) + '\n');
300
- }
301
- catch (e) { }
302
- try {
303
- console.error('MCP-STARTAPP-EXEC', JSON.stringify(instrumentation));
304
- }
305
- catch (e) { }
306
- }
307
- catch { }
308
- // Attempt to launch
309
- let launchResult = null;
310
- try {
311
- launchResult = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
312
- }
313
- catch (launchErr) {
314
- // Collect diagnostics when simctl launch fails
315
- const launchDiag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
295
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
316
296
  const device = await getIOSDeviceMetadata(deviceId);
317
- const post = await this.collectPostLaunchDiagnostics(bundleId, deviceId);
318
- return { device, appStarted: false, launchTimeMs: 0, error: launchErr instanceof Error ? launchErr.message : String(launchErr), diagnostics: { launchDiag, post }, instrumentation };
319
- }
320
- // Basic success — but verify RunningBoard/installcoordination didn't mark it as placeholder
321
- const device = await getIOSDeviceMetadata(deviceId);
322
- // short wait to let system settle
323
- await new Promise(r => setTimeout(r, 1000));
324
- let appinfo = '';
325
- try {
326
- const ai = await execCommand(['simctl', 'appinfo', deviceId, bundleId], deviceId);
327
- appinfo = ai.output || '';
328
- }
329
- catch { }
330
- // capture recent runningboard/installcoordination logs
331
- const logDiag = execCommandWithDiagnostics(['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--predicate', `(process == "${bundleId}" ) OR eventMessage CONTAINS "installcoordinationd" OR eventMessage CONTAINS "runningboard"`, '--last', '1m'], deviceId);
332
- const placeholderDetected = (appinfo && /isPlaceholder[:=]?\s*Y/i.test(appinfo)) || (logDiag && ((logDiag.runResult && ((logDiag.runResult.stdout || '').includes('isPlaceholder')) || (logDiag.runResult.stderr || '').includes('isPlaceholder'))));
333
- if (placeholderDetected) {
334
- const post = await this.collectPostLaunchDiagnostics(bundleId, deviceId, appinfo);
335
- return { device, appStarted: false, launchTimeMs: 0, diagnostics: { appinfo, logDiag, post }, instrumentation };
336
- }
337
- return { device, appStarted: !!(launchResult && launchResult.output), launchTimeMs: 1000, instrumentation };
338
- }
339
- appExecutableName(bundleId) {
340
- // Best-effort executable name: prefer last component of bundleId
341
- try {
342
- const candidate = bundleId.split('.').pop();
343
- return candidate || bundleId;
344
- }
345
- catch {
346
- return bundleId;
347
- }
348
- }
349
- // Collect bundle- and system-level diagnostics after a failed or placeholder launch
350
- async collectPostLaunchDiagnostics(bundleId, deviceId = "booted", appinfo) {
351
- const diagnostics = { ts: new Date().toISOString(), bundleId, deviceId };
352
- // gather simctl appinfo (if not provided)
353
- try {
354
- diagnostics.appinfo = appinfo || ((await execCommand(['simctl', 'appinfo', deviceId, bundleId], deviceId)).output || '');
355
- }
356
- catch (e) {
357
- diagnostics.appinfoError = String(e);
358
- }
359
- // attempt to discover bundle path from appinfo
360
- let bundlePath = null;
361
- if (diagnostics.appinfo) {
362
- const m = diagnostics.appinfo.match(/Path\s*=\s*"?([\S]+)"?/) || diagnostics.appinfo.match(/Container: (\/\S+)/);
363
- if (m)
364
- bundlePath = m[1];
365
- }
366
- // lipo / file / otool / codesign / xattr
367
- if (bundlePath) {
368
- diagnostics.bundlePath = bundlePath;
369
- const execs = [
370
- { name: 'file', cmd: ['file', bundlePath + '/' + this.appExecutableName(bundleId)] },
371
- { name: 'lipo', cmd: ['lipo', '-info', bundlePath + '/' + this.appExecutableName(bundleId)] },
372
- { name: 'otool-L', cmd: ['otool', '-L', bundlePath + '/' + this.appExecutableName(bundleId)] },
373
- { name: 'otool-load', cmd: ['otool', '-l', bundlePath + '/' + this.appExecutableName(bundleId)] },
374
- { name: 'plutil', cmd: ['plutil', '-p', bundlePath + '/Info.plist'] },
375
- { name: 'codesign', cmd: ['codesign', '-dvvv', bundlePath] },
376
- { name: 'xattr', cmd: ['xattr', '-l', bundlePath] },
377
- { name: 'ls', cmd: ['ls', '-la', bundlePath] },
378
- ];
379
- diagnostics.bundle = {};
380
- for (const e of execs) {
381
- try {
382
- const r = execCommandWithDiagnostics(e.cmd, deviceId);
383
- diagnostics.bundle[e.name] = r && r.runResult ? { stdout: r.runResult.stdout, stderr: r.runResult.stderr, code: r.runResult.exitCode } : { error: 'no-result' };
384
- }
385
- catch (err) {
386
- diagnostics.bundle[e.name] = { error: String(err) };
387
- }
388
- }
389
- }
390
- // collect recent system logs and a screenshot
391
- try {
392
- diagnostics.recentLogs = execCommandWithDiagnostics(['simctl', 'spawn', deviceId, 'log', 'show', '--style', 'syslog', '--predicate', `eventMessage CONTAINS "installcoordinationd" OR eventMessage CONTAINS "runningboard"`, '--last', '5m'], deviceId);
393
- }
394
- catch (e) {
395
- diagnostics.recentLogsError = String(e);
396
- }
397
- try {
398
- const shot = await execCommandWithDiagnostics(['simctl', 'io', deviceId, 'screenshot', '--type', 'png', '/tmp/mcp_post_launch_screenshot.png'], deviceId);
399
- diagnostics.screenshot = { created: true, path: '/tmp/mcp_post_launch_screenshot.png', result: shot && shot.runResult };
297
+ return { device, appStarted: !!result.output, launchTimeMs: 1000 };
400
298
  }
401
299
  catch (e) {
402
- diagnostics.screenshotError = String(e);
300
+ const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
301
+ const device = await getIOSDeviceMetadata(deviceId);
302
+ return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
403
303
  }
404
- return diagnostics;
405
304
  }
406
305
  async terminateApp(bundleId, deviceId = "booted") {
407
306
  validateBundleId(bundleId);
package/dist/ios/utils.js CHANGED
@@ -96,40 +96,7 @@ export function validateBundleId(bundleId) {
96
96
  }
97
97
  export function execCommand(args, deviceId = "booted") {
98
98
  return new Promise((resolve, reject) => {
99
- // Instrumentation: append a JSON line with timestamp, command, args, cwd and selected env vars
100
- try {
101
- const mcpEnv = {};
102
- for (const k of Object.keys(process.env || {})) {
103
- if (k.startsWith('MCP_'))
104
- mcpEnv[k] = process.env[k];
105
- }
106
- const instrument = {
107
- timestamp: new Date().toISOString(),
108
- command: getXcrunCmd(),
109
- args,
110
- cwd: process.cwd(),
111
- env: {
112
- PATH: process.env.PATH,
113
- XCRUN_PATH: process.env.XCRUN_PATH,
114
- ...mcpEnv
115
- }
116
- };
117
- try {
118
- require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(instrument) + '\n');
119
- }
120
- catch (e) { }
121
- }
122
- catch (e) {
123
- // swallow instrumentation errors to avoid changing behavior
124
- }
125
99
  // Use spawn for better stream control and consistency with Android implementation
126
- // Instrument: emit a JSON line to stderr so the MCP server stderr/stdout capture can record the exact command and env
127
- try {
128
- const instLine = JSON.stringify({ ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd(), PATH: process.env.PATH });
129
- // Use stderr so it appears in server logs reliably
130
- console.error('MCP-INSTRUMENT-EXEC', instLine);
131
- }
132
- catch (e) { }
133
100
  const child = spawn(getXcrunCmd(), args);
134
101
  let stdout = '';
135
102
  let stderr = '';
@@ -143,17 +110,6 @@ export function execCommand(args, deviceId = "booted") {
143
110
  stderr += data.toString();
144
111
  });
145
112
  }
146
- // Additional instrumentation: write pid and env snapshot when child starts
147
- try {
148
- const pidInfo = { ts: new Date().toISOString(), childPid: (child.pid || null), invoked: getXcrunCmd(), args };
149
- try {
150
- require('fs').appendFileSync('/tmp/mcp_exec_instrument.log', JSON.stringify(pidInfo) + '\n');
151
- }
152
- catch (e) { }
153
- }
154
- catch (e) {
155
- // ignore
156
- }
157
113
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000; // env (ms) or default 30s
158
114
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000; // env (ms) or default 60s
159
115
  const timeoutMs = args.includes('log') ? DEFAULT_XCRUN_LOG_TIMEOUT : DEFAULT_XCRUN_CMD_TIMEOUT; // choose appropriate timeout
@@ -177,11 +133,6 @@ export function execCommand(args, deviceId = "booted") {
177
133
  });
178
134
  }
179
135
  export function execCommandWithDiagnostics(args, deviceId = "booted") {
180
- try {
181
- const syncInst = { ts: new Date().toISOString(), cmd: getXcrunCmd(), args, cwd: process.cwd() };
182
- require('fs').appendFileSync('/tmp/mcp_exec_instrument_sync.log', JSON.stringify(syncInst) + '\n');
183
- }
184
- catch (e) { }
185
136
  // Run synchronously to capture stdout/stderr and exitCode reliably for diagnostics
186
137
  const DEFAULT_XCRUN_LOG_TIMEOUT = parseInt(process.env.MCP_XCRUN_LOG_TIMEOUT || '', 10) || 30000;
187
138
  const DEFAULT_XCRUN_CMD_TIMEOUT = parseInt(process.env.MCP_XCRUN_TIMEOUT || '', 10) || 60000;
package/dist/server.js CHANGED
@@ -325,8 +325,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
325
325
  properties: {
326
326
  platform: {
327
327
  type: "string",
328
- enum: ["android"],
329
- description: "Platform to swipe on (currently only android supported)"
328
+ enum: ["android", "ios"],
329
+ description: "Platform to swipe on (android or ios)"
330
330
  },
331
331
  x1: { type: "number", description: "Start X coordinate" },
332
332
  y1: { type: "number", description: "Start Y coordinate" },
@@ -341,6 +341,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
341
341
  required: ["x1", "y1", "x2", "y2", "duration"]
342
342
  }
343
343
  },
344
+ {
345
+ name: "scroll_to_element",
346
+ description: "Scroll the current screen until a target UI element becomes visible, then return its details.",
347
+ inputSchema: {
348
+ type: "object",
349
+ properties: {
350
+ platform: { type: "string", enum: ["android", "ios"], description: "Platform to operate on (required)" },
351
+ selector: {
352
+ type: "object",
353
+ properties: {
354
+ text: { type: "string" },
355
+ resourceId: { type: "string" },
356
+ contentDesc: { type: "string" },
357
+ className: { type: "string" }
358
+ }
359
+ },
360
+ direction: { type: "string", enum: ["down", "up"], default: "down" },
361
+ maxScrolls: { type: "number", default: 10 },
362
+ scrollAmount: { type: "number", default: 0.7 },
363
+ deviceId: { type: "string", description: "Device UDID (iOS) or Serial (Android). Defaults to booted/connected." }
364
+ },
365
+ required: ["platform", "selector"]
366
+ }
367
+ },
344
368
  {
345
369
  name: "type_text",
346
370
  description: "Type text into the currently focused input field on an Android device.",
@@ -389,37 +413,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
389
413
  try {
390
414
  if (name === "start_app") {
391
415
  const { platform, appId, deviceId } = args;
392
- // Defensive validation: ensure required args are present and log malformed requests
416
+ // Defensive validation: ensure caller provided platform and appId.
393
417
  if (!platform || !appId) {
418
+ const msg = 'Both platform and appId parameters are required (platform: ios|android, appId: bundle id or package name).';
419
+ const payload = { ts: new Date().toISOString(), tool: 'start_app', args };
420
+ let logged = false;
421
+ // Prefer the diagnostics module when available
394
422
  try {
395
- require('fs').appendFileSync('/tmp/mcp_bad_requests.log', JSON.stringify({ ts: new Date().toISOString(), tool: 'start_app', args }) + '\n');
423
+ const diag = require('./utils/diagnostics.js');
424
+ if (diag && diag.appendDiagnosticFile) {
425
+ diag.appendDiagnosticFile('bad_requests.log', payload);
426
+ logged = true;
427
+ }
396
428
  }
397
- catch (e) { }
398
- const deviceFallback = { platform: platform || 'ios', id: deviceId || 'unknown', osVersion: '', model: '', simulator: true };
399
- const response = { device: deviceFallback, appStarted: false, launchTimeMs: 0, error: 'Missing required argument: platform and/or appId', diagnostics: { receivedArgs: args } };
400
- return wrapResponse(response);
401
- }
402
- try {
403
- const res = await (platform === 'android' ? new AndroidManage().startApp(appId, deviceId) : new iOSManage().startApp(appId, deviceId));
404
- // Preserve diagnostics and instrumentation from platform managers so agents receive full context
405
- const response = {
406
- device: res.device,
407
- appStarted: res.appStarted,
408
- launchTimeMs: res.launchTimeMs,
409
- error: res.error,
410
- diagnostics: res.diagnostics,
411
- instrumentation: res.instrumentation
412
- };
413
- return wrapResponse(response);
414
- }
415
- catch (err) {
416
- try {
417
- require('fs').appendFileSync('/tmp/mcp_bad_requests.log', JSON.stringify({ ts: new Date().toISOString(), tool: 'start_app', args, error: err && err.message ? err.message : String(err) }) + '\n');
429
+ catch (err) {
430
+ console.error('Diagnostics append failed:', String(err));
431
+ }
432
+ // Fallback to /tmp file (synchronous) and report failures rather than swallowing
433
+ if (!logged) {
434
+ try {
435
+ const fs = require('fs');
436
+ fs.appendFileSync('/tmp/mcp_bad_requests.log', JSON.stringify(payload) + '\n');
437
+ logged = true;
438
+ }
439
+ catch (err) {
440
+ console.error('Failed to write bad request to /tmp/mcp_bad_requests.log:', String(err));
441
+ }
442
+ }
443
+ // Final fallback: emit payload to stderr so it's visible in server logs
444
+ if (!logged) {
445
+ try {
446
+ console.error('Bad request (start_app) payload:', JSON.stringify(payload));
447
+ }
448
+ catch (err) {
449
+ // Last resort: still log the failure
450
+ console.error('Failed to emit bad request payload to stderr:', String(err));
451
+ }
418
452
  }
419
- catch (e) { }
420
- const deviceFallback = { platform: platform || 'ios', id: deviceId || 'unknown', osVersion: '', model: '', simulator: true };
421
- return wrapResponse({ device: deviceFallback, appStarted: false, launchTimeMs: 0, error: err instanceof Error ? err.message : String(err), diagnostics: { receivedArgs: args } });
453
+ return wrapResponse({ error: msg });
422
454
  }
455
+ const res = await (platform === 'android' ? new AndroidManage().startApp(appId, deviceId) : new iOSManage().startApp(appId, deviceId));
456
+ const response = {
457
+ device: res.device,
458
+ appStarted: res.appStarted,
459
+ launchTimeMs: res.launchTimeMs
460
+ };
461
+ return wrapResponse(response);
423
462
  }
424
463
  if (name === "terminate_app") {
425
464
  const { platform, appId, deviceId } = args;
@@ -512,8 +551,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
512
551
  return wrapResponse(res);
513
552
  }
514
553
  if (name === "swipe") {
515
- const { x1, y1, x2, y2, duration, deviceId } = (args || {});
516
- const res = await ToolsInteract.swipeHandler({ x1, y1, x2, y2, duration, deviceId });
554
+ const { platform = 'android', x1, y1, x2, y2, duration, deviceId } = (args || {});
555
+ const res = await ToolsInteract.swipeHandler({ platform, x1, y1, x2, y2, duration, deviceId });
556
+ return wrapResponse(res);
557
+ }
558
+ if (name === "scroll_to_element") {
559
+ const { platform, selector, direction, maxScrolls, scrollAmount, deviceId } = (args || {});
560
+ const res = await ToolsInteract.scrollToElementHandler({ platform, selector, direction, maxScrolls, scrollAmount, deviceId });
517
561
  return wrapResponse(res);
518
562
  }
519
563
  if (name === "type_text") {
@@ -2,31 +2,24 @@ import { resolveTargetDevice } from '../utils/resolve-device.js';
2
2
  import { AndroidInteract } from '../android/interact.js';
3
3
  import { iOSInteract } from '../ios/interact.js';
4
4
  export class ToolsInteract {
5
+ static async getInteractionService(platform, deviceId) {
6
+ const effectivePlatform = platform || 'android';
7
+ const resolved = await resolveTargetDevice({ platform: effectivePlatform, deviceId });
8
+ const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
9
+ return { interact: interact, resolved, platform: effectivePlatform };
10
+ }
5
11
  static async waitForElementHandler({ platform, text, timeout, deviceId }) {
6
12
  const effectiveTimeout = timeout ?? 10000;
7
- if (platform === 'android') {
8
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
9
- return await new AndroidInteract().waitForElement(text, effectiveTimeout, resolved.id);
10
- }
11
- else {
12
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
13
- return await new iOSInteract().waitForElement(text, effectiveTimeout, resolved.id);
14
- }
13
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
14
+ return await interact.waitForElement(text, effectiveTimeout, resolved.id);
15
15
  }
16
16
  static async tapHandler({ platform, x, y, deviceId }) {
17
- const effectivePlatform = platform || 'android';
18
- if (effectivePlatform === 'android') {
19
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
20
- return await new AndroidInteract().tap(x, y, resolved.id);
21
- }
22
- else {
23
- const resolved = await resolveTargetDevice({ platform: 'ios', deviceId });
24
- return await new iOSInteract().tap(x, y, resolved.id);
25
- }
17
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
18
+ return await interact.tap(x, y, resolved.id);
26
19
  }
27
- static async swipeHandler({ x1, y1, x2, y2, duration, deviceId }) {
28
- const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
29
- return await new AndroidInteract().swipe(x1, y1, x2, y2, duration, resolved.id);
20
+ static async swipeHandler({ platform = 'android', x1, y1, x2, y2, duration, deviceId }) {
21
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
22
+ return await interact.swipe(x1, y1, x2, y2, duration, resolved.id);
30
23
  }
31
24
  static async typeTextHandler({ text, deviceId }) {
32
25
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
@@ -36,4 +29,8 @@ export class ToolsInteract {
36
29
  const resolved = await resolveTargetDevice({ platform: 'android', deviceId });
37
30
  return await new AndroidInteract().pressBack(resolved.id);
38
31
  }
32
+ static async scrollToElementHandler({ platform, selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId }) {
33
+ const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
34
+ return await interact.scrollToElement(selector, direction, maxScrolls, scrollAmount, resolved.id);
35
+ }
39
36
  }
@@ -0,0 +1,98 @@
1
+ export async function scrollToElementShared(opts) {
2
+ const { selector, direction = 'down', maxScrolls = 10, scrollAmount = 0.7, deviceId, fetchTree, swipe, stabilizationDelayMs = 350 } = opts;
3
+ const matchElement = (el) => {
4
+ if (!el)
5
+ return false;
6
+ if (selector.text !== undefined && selector.text !== el.text)
7
+ return false;
8
+ if (selector.resourceId !== undefined && selector.resourceId !== el.resourceId)
9
+ return false;
10
+ if (selector.contentDesc !== undefined && selector.contentDesc !== el.contentDescription)
11
+ return false;
12
+ if (selector.className !== undefined && selector.className !== el.type)
13
+ return false;
14
+ return true;
15
+ };
16
+ const isVisible = (el, resolution) => {
17
+ if (!el)
18
+ return false;
19
+ if (el.visible === false)
20
+ return false;
21
+ if (!el.bounds || !resolution || !resolution.width || !resolution.height)
22
+ return (el.visible === undefined ? true : !!el.visible);
23
+ const [left, top, right, bottom] = el.bounds;
24
+ const withinY = bottom > 0 && top < resolution.height;
25
+ const withinX = right > 0 && left < resolution.width;
26
+ return withinX && withinY;
27
+ };
28
+ const findVisibleMatch = (elements, resolution) => {
29
+ if (!Array.isArray(elements))
30
+ return null;
31
+ for (const e of elements) {
32
+ if (matchElement(e) && isVisible(e, resolution))
33
+ return e;
34
+ }
35
+ return null;
36
+ };
37
+ // Initial check
38
+ let tree = await fetchTree();
39
+ if (tree.error)
40
+ return { success: false, reason: tree.error, scrollsPerformed: 0 };
41
+ let found = findVisibleMatch(tree.elements, tree.resolution);
42
+ if (found) {
43
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed: 0 };
44
+ }
45
+ const fingerprintOf = (t) => {
46
+ try {
47
+ return JSON.stringify((t.elements || []).map((e) => ({ text: e.text, resourceId: e.resourceId, bounds: e.bounds })));
48
+ }
49
+ catch {
50
+ return '';
51
+ }
52
+ };
53
+ let prevFingerprint = fingerprintOf(tree);
54
+ const width = (tree.resolution && tree.resolution.width) ? tree.resolution.width : 0;
55
+ const height = (tree.resolution && tree.resolution.height) ? tree.resolution.height : 0;
56
+ const centerX = Math.round(width / 2) || 50;
57
+ const clampPct = (v) => Math.max(0.05, Math.min(0.95, v));
58
+ const computeCoords = () => {
59
+ const defaultStart = direction === 'down' ? 0.8 : 0.2;
60
+ const startPct = clampPct(defaultStart);
61
+ const endPct = clampPct(defaultStart + (direction === 'down' ? -scrollAmount : scrollAmount));
62
+ const x1 = centerX;
63
+ const x2 = centerX;
64
+ const y1 = Math.round((height || 100) * startPct);
65
+ const y2 = Math.round((height || 100) * endPct);
66
+ return { x1, y1, x2, y2 };
67
+ };
68
+ const duration = 300;
69
+ let scrollsPerformed = 0;
70
+ for (let i = 0; i < maxScrolls; i++) {
71
+ const { x1, y1, x2, y2 } = computeCoords();
72
+ try {
73
+ await swipe(x1, y1, x2, y2, duration, deviceId);
74
+ }
75
+ catch (e) {
76
+ // Log swipe failures to aid debugging but don't fail the overall flow
77
+ try {
78
+ console.warn(`scrollToElement swipe failed: ${e instanceof Error ? e.message : String(e)}`);
79
+ }
80
+ catch { }
81
+ }
82
+ scrollsPerformed++;
83
+ await new Promise(resolve => setTimeout(resolve, stabilizationDelayMs));
84
+ tree = await fetchTree();
85
+ if (tree.error)
86
+ return { success: false, reason: tree.error, scrollsPerformed: scrollsPerformed };
87
+ found = findVisibleMatch(tree.elements, tree.resolution);
88
+ if (found) {
89
+ return { success: true, element: { text: found.text, resourceId: found.resourceId, bounds: found.bounds }, scrollsPerformed };
90
+ }
91
+ const fp = fingerprintOf(tree);
92
+ if (fp === prevFingerprint) {
93
+ return { success: false, reason: 'UI unchanged after scroll; likely end of list', scrollsPerformed: scrollsPerformed };
94
+ }
95
+ prevFingerprint = fp;
96
+ }
97
+ return { success: false, reason: 'Element not found after scrolling', scrollsPerformed: scrollsPerformed };
98
+ }
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to the **Mobile Debug MCP** project will be documented in this file.
4
4
 
5
+ ## [0.14.0]
6
+ - Added `scroll_to_element` tool: platform-aware helper that scrolls until a UI element matching a selector is visible. Supports Android and iOS with configurable options: direction, maxScrolls, and scrollAmount. Includes unit tests and device runners under `test/device/` for manual E2E validation.
7
+ - Moved scroll logic into platform-specific implementations (`src/android/interact.ts`, `src/ios/interact.ts`) and delegated from `src/tools/interact.ts` to centralise platform behaviour.
8
+ - Fixed iOS `idb` swipe arguments and improved visibility detection by using element bounds and device resolution to avoid treating off-screen elements as visible.
9
+ - Consolidated unit tests for `scroll_to_element` into `test/unit/observe/scroll_to_element.test.ts`, and removed older duplicate test files.
10
+
11
+
12
+ ## [0.13.0]
13
+ - Fixed a crash in the `start_app` tool by adding validation to ensure `appId` and `platform` are provided.
14
+
5
15
  ## [0.12.4]
6
16
  - Made projectType and platform mandatory
7
17
 
@@ -4,8 +4,8 @@ This repository groups tool docs into three areas aligned with the codebase: man
4
4
 
5
5
  See:
6
6
 
7
- - docs/manage.md — build, install and device management tools
8
- - docs/observe.md — logs, screenshots and UI inspection tools
9
- - docs/interact.md — UI interaction tools (tap, swipe, type, wait)
7
+ - [mange](manage.md) — build, install and device management tools
8
+ - [observe](observe.md) — logs, screenshots and UI inspection tools
9
+ - [interact](interact.md) — UI interaction tools (tap, swipe, type, wait)
10
10
 
11
11
  For per-tool deep dives, open the linked files above.
@@ -41,3 +41,34 @@ Notes:
41
41
  - swipe: `adb shell input swipe x1 y1 x2 y2 duration`.
42
42
  - type_text: `adb shell input text` (spaces encoded as %s) — may fail for special characters.
43
43
  - press_back: `adb shell input keyevent 4`.
44
+
45
+ ---
46
+
47
+ ## scroll_to_element
48
+
49
+ Description:
50
+ - Scrolls the UI until an element matching the provided selector becomes visible, or until a maximum number of scroll attempts is reached.
51
+ - Delegates platform behaviour to Android and iOS implementations for reliable swipes and UI-tree checks.
52
+
53
+ Input example:
54
+ ```
55
+ { "platform": "android", "selector": { "text": "Offscreen Test Element" }, "direction": "down", "maxScrolls": 10, "scrollAmount": 0.7, "deviceId": "emulator-5554" }
56
+ ```
57
+
58
+ Response example (found):
59
+ ```
60
+ { "success": true, "reason": "element_found", "element": { /* element metadata */ }, "scrollsPerformed": 2 }
61
+ ```
62
+
63
+ Response example (failure - unchanged UI):
64
+ ```
65
+ { "success": false, "reason": "ui_unchanged_after_scroll", "scrollsPerformed": 3 }
66
+ ```
67
+
68
+ Notes:
69
+ - Matching is exact on provided selector fields (text, resourceId, contentDesc, className).
70
+ - Visibility check uses element.bounds intersecting the device resolution when available; falls back to the element.visible flag if bounds/resolution are missing.
71
+ - The tool fingerprints the visible UI between scrolls; if the fingerprint doesn't change after a swipe the tool stops early assuming end-of-list.
72
+ - Android swipe uses `adb shell input swipe` with screen percentage coordinates. iOS swipe uses `idb ui swipe` command; note `idb` swipe does not accept a duration argument.
73
+ - Unit tests are located at `test/unit/observe/scroll_to_element.test.ts` and device runners at `test/device/observe/`.
74
+