react-native-ai-debugger 1.1.6 → 1.4.33

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.
Files changed (168) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +386 -45
  3. package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
  4. package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
  5. package/build/__tests__/helpers/tap-test-helpers.js +130 -0
  6. package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
  7. package/build/__tests__/integration/connection-health.test.js +12 -6
  8. package/build/__tests__/integration/connection-health.test.js.map +1 -1
  9. package/build/__tests__/integration/execute-in-app.test.js +2 -1
  10. package/build/__tests__/integration/execute-in-app.test.js.map +1 -1
  11. package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
  12. package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
  13. package/build/__tests__/integration/tap-strategies.test.js +329 -0
  14. package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
  15. package/build/__tests__/integration/tools.test.js +9 -10
  16. package/build/__tests__/integration/tools.test.js.map +1 -1
  17. package/build/__tests__/unit/burst.test.d.ts +2 -0
  18. package/build/__tests__/unit/burst.test.d.ts.map +1 -0
  19. package/build/__tests__/unit/burst.test.js +50 -0
  20. package/build/__tests__/unit/burst.test.js.map +1 -0
  21. package/build/__tests__/unit/connection-health.test.js +26 -16
  22. package/build/__tests__/unit/connection-health.test.js.map +1 -1
  23. package/build/__tests__/unit/fiberMatch.test.d.ts +2 -0
  24. package/build/__tests__/unit/fiberMatch.test.d.ts.map +1 -0
  25. package/build/__tests__/unit/fiberMatch.test.js +114 -0
  26. package/build/__tests__/unit/fiberMatch.test.js.map +1 -0
  27. package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
  28. package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
  29. package/build/__tests__/unit/fingerprint.test.js +89 -0
  30. package/build/__tests__/unit/fingerprint.test.js.map +1 -0
  31. package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
  32. package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
  33. package/build/__tests__/unit/imageBuffer.test.js +128 -0
  34. package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
  35. package/build/__tests__/unit/logs.test.js +9 -0
  36. package/build/__tests__/unit/logs.test.js.map +1 -1
  37. package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
  38. package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
  39. package/build/__tests__/unit/networkInterceptor.test.js +104 -0
  40. package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
  41. package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
  42. package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
  43. package/build/__tests__/unit/screenshot-diff.test.js +97 -0
  44. package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
  45. package/build/__tests__/unit/tap.test.js +542 -25
  46. package/build/__tests__/unit/tap.test.js.map +1 -1
  47. package/build/core/android.d.ts.map +1 -1
  48. package/build/core/android.js +104 -113
  49. package/build/core/android.js.map +1 -1
  50. package/build/core/appDetection.d.ts +8 -0
  51. package/build/core/appDetection.d.ts.map +1 -0
  52. package/build/core/appDetection.js +115 -0
  53. package/build/core/appDetection.js.map +1 -0
  54. package/build/core/bundle.d.ts.map +1 -1
  55. package/build/core/bundle.js +15 -23
  56. package/build/core/bundle.js.map +1 -1
  57. package/build/core/config.d.ts +8 -0
  58. package/build/core/config.d.ts.map +1 -0
  59. package/build/core/config.js +27 -0
  60. package/build/core/config.js.map +1 -0
  61. package/build/core/connection.d.ts +47 -3
  62. package/build/core/connection.d.ts.map +1 -1
  63. package/build/core/connection.js +519 -124
  64. package/build/core/connection.js.map +1 -1
  65. package/build/core/executor.d.ts +82 -14
  66. package/build/core/executor.d.ts.map +1 -1
  67. package/build/core/executor.js +1869 -418
  68. package/build/core/executor.js.map +1 -1
  69. package/build/core/feedback.d.ts +18 -0
  70. package/build/core/feedback.d.ts.map +1 -0
  71. package/build/core/feedback.js +73 -0
  72. package/build/core/feedback.js.map +1 -0
  73. package/build/core/fiberMatch.d.ts +25 -0
  74. package/build/core/fiberMatch.d.ts.map +1 -0
  75. package/build/core/fiberMatch.js +35 -0
  76. package/build/core/fiberMatch.js.map +1 -0
  77. package/build/core/fingerprint.d.ts +4 -0
  78. package/build/core/fingerprint.d.ts.map +1 -0
  79. package/build/core/fingerprint.js +47 -0
  80. package/build/core/fingerprint.js.map +1 -0
  81. package/build/core/guides.d.ts.map +1 -1
  82. package/build/core/guides.js +66 -3
  83. package/build/core/guides.js.map +1 -1
  84. package/build/core/imageBuffer.d.ts +37 -0
  85. package/build/core/imageBuffer.d.ts.map +1 -0
  86. package/build/core/imageBuffer.js +58 -0
  87. package/build/core/imageBuffer.js.map +1 -0
  88. package/build/core/index.d.ts +22 -17
  89. package/build/core/index.d.ts.map +1 -1
  90. package/build/core/index.js +23 -19
  91. package/build/core/index.js.map +1 -1
  92. package/build/core/ios.d.ts +36 -7
  93. package/build/core/ios.d.ts.map +1 -1
  94. package/build/core/ios.js +561 -326
  95. package/build/core/ios.js.map +1 -1
  96. package/build/core/license.d.ts +38 -0
  97. package/build/core/license.d.ts.map +1 -0
  98. package/build/core/license.js +243 -0
  99. package/build/core/license.js.map +1 -0
  100. package/build/core/logbox.d.ts +57 -0
  101. package/build/core/logbox.d.ts.map +1 -0
  102. package/build/core/logbox.js +282 -0
  103. package/build/core/logbox.js.map +1 -0
  104. package/build/core/logs.d.ts +1 -0
  105. package/build/core/logs.d.ts.map +1 -1
  106. package/build/core/logs.js +5 -0
  107. package/build/core/logs.js.map +1 -1
  108. package/build/core/metro.d.ts +8 -0
  109. package/build/core/metro.d.ts.map +1 -1
  110. package/build/core/metro.js +28 -0
  111. package/build/core/metro.js.map +1 -1
  112. package/build/core/networkInterceptor.d.ts +29 -2
  113. package/build/core/networkInterceptor.d.ts.map +1 -1
  114. package/build/core/networkInterceptor.js +195 -191
  115. package/build/core/networkInterceptor.js.map +1 -1
  116. package/build/core/ocr.d.ts +8 -3
  117. package/build/core/ocr.d.ts.map +1 -1
  118. package/build/core/ocr.js +116 -15
  119. package/build/core/ocr.js.map +1 -1
  120. package/build/core/posthog.d.ts +5 -0
  121. package/build/core/posthog.d.ts.map +1 -0
  122. package/build/core/posthog.js +46 -0
  123. package/build/core/posthog.js.map +1 -0
  124. package/build/core/sdkBridge.d.ts +67 -0
  125. package/build/core/sdkBridge.d.ts.map +1 -0
  126. package/build/core/sdkBridge.js +148 -0
  127. package/build/core/sdkBridge.js.map +1 -0
  128. package/build/core/state.d.ts +25 -5
  129. package/build/core/state.d.ts.map +1 -1
  130. package/build/core/state.js +79 -10
  131. package/build/core/state.js.map +1 -1
  132. package/build/core/telemetry.d.ts +21 -1
  133. package/build/core/telemetry.d.ts.map +1 -1
  134. package/build/core/telemetry.js +166 -10
  135. package/build/core/telemetry.js.map +1 -1
  136. package/build/core/types.d.ts +11 -0
  137. package/build/core/types.d.ts.map +1 -1
  138. package/build/index.js +1490 -324
  139. package/build/index.js.map +1 -1
  140. package/build/pro/screenshot-diff.d.ts +10 -0
  141. package/build/pro/screenshot-diff.d.ts.map +1 -0
  142. package/build/pro/screenshot-diff.js +38 -0
  143. package/build/pro/screenshot-diff.js.map +1 -0
  144. package/build/pro/tap.d.ts +84 -2
  145. package/build/pro/tap.d.ts.map +1 -1
  146. package/build/pro/tap.js +835 -129
  147. package/build/pro/tap.js.map +1 -1
  148. package/build/tools/accountTools.d.ts +44 -0
  149. package/build/tools/accountTools.d.ts.map +1 -0
  150. package/build/tools/accountTools.js +217 -0
  151. package/build/tools/accountTools.js.map +1 -0
  152. package/package.json +8 -7
  153. package/build/core/httpServer.d.ts +0 -14
  154. package/build/core/httpServer.d.ts.map +0 -1
  155. package/build/core/httpServer.js +0 -2459
  156. package/build/core/httpServer.js.map +0 -1
  157. package/build/core/httpServerProcess.d.ts +0 -25
  158. package/build/core/httpServerProcess.d.ts.map +0 -1
  159. package/build/core/httpServerProcess.js +0 -153
  160. package/build/core/httpServerProcess.js.map +0 -1
  161. package/build/core/tap.d.ts +0 -91
  162. package/build/core/tap.d.ts.map +0 -1
  163. package/build/core/tap.js +0 -542
  164. package/build/core/tap.js.map +0 -1
  165. package/build/httpServerStandalone.d.ts +0 -7
  166. package/build/httpServerStandalone.d.ts.map +0 -1
  167. package/build/httpServerStandalone.js +0 -31
  168. package/build/httpServerStandalone.js.map +0 -1
package/build/index.js CHANGED
@@ -3,14 +3,21 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
5
  import { createServer as createHttpServer } from "node:http";
6
+ import { existsSync, unlinkSync } from "fs";
6
7
  import { z } from "zod";
7
8
  import { getGuideOverview, getGuideByTopic, getAvailableTopics } from "./core/guides.js";
9
+ import { getLicenseStatus, getDashboardUrl, getUsageInfo } from "./core/license.js";
10
+ import { API_BASE_URL } from "./core/config.js";
11
+ import { getPostHogClient, identifyIfDevMode, shutdownPostHog } from "./core/posthog.js";
12
+ import { getInstallationId, getServerVersion, isDevMode, TELEMETRY_JSONL_PATH, categorizeError } from "./core/telemetry.js";
13
+ import { isSDKInstalled, querySDKNetwork, getSDKNetworkEntry, getSDKNetworkStats, clearSDKNetwork, querySDKConsole, getSDKConsoleStats, clearSDKConsole } from "./core/sdkBridge.js";
8
14
  import { tap } from "./pro/tap.js";
9
- import { logBuffer, networkBuffer, bundleErrorBuffer, connectedApps, getActiveSimulatorUdid, scanMetroPorts, fetchDevices, selectMainDevice, connectToDevice, getConnectedApps, executeInApp, listDebugGlobals, inspectGlobal, reloadApp,
15
+ import { getActivateLicenseConfig, handleActivateLicense, getDeleteAccountConfig, handleDeleteAccount, } from "./tools/accountTools.js";
16
+ import { logBuffers, networkBuffers, getLogBuffer, getNetworkBuffer, getTotalLogCount, getConnectedAppByDevice, LogBuffer, NetworkBuffer, bundleErrorBuffer, imageBuffer, connectedApps, scanMetroPorts, fetchDevices, filterDebuggableDevices, connectToDevice, getConnectedApps, executeInApp, listDebugGlobals, inspectGlobal, reloadApp,
10
17
  // React Component Inspection
11
- getComponentTree, getScreenLayout, inspectComponent, findComponents, inspectAtPoint, toggleElementInspector, isInspectorActive, getInspectorSelection, getFirstConnectedApp, getLogs, searchLogs, getLogSummary, getNetworkRequests, searchNetworkRequests, getNetworkStats, formatRequestDetails,
18
+ getComponentTree, getScreenLayout, getPressableElements, inspectComponent, findComponents, inspectAtPoint, toggleElementInspector, isInspectorActive, getInspectorSelection, getFirstConnectedApp, getLogs, searchLogs, getLogSummary, getNetworkRequests, searchNetworkRequests, getNetworkStats, formatRequestDetails,
12
19
  // Connection state
13
- getAllConnectionStates, getAllConnectionMetadata, getRecentGaps, formatDuration, cancelAllReconnectionTimers, clearAllConnectionState, suppressReconnection, clearReconnectionSuppression,
20
+ getAllConnectionStates, getAllConnectionMetadata, getRecentGaps, formatDuration, cancelAllReconnectionTimers, cancelReconnectionTimer, clearAllConnectionState, clearAllCDPMessageTimes, suppressReconnection, suppressReconnectionForKey, clearReconnectionSuppression, purgeStaleConnectionsForPorts,
14
21
  // Context health tracking
15
22
  getContextHealth,
16
23
  // Connection resilience
@@ -20,7 +27,7 @@ connectMetroBuildEvents, disconnectMetroBuildEvents, getBundleErrors, getBundleS
20
27
  // Error screen parsing (OCR fallback)
21
28
  parseErrorScreenText, formatParsedError,
22
29
  // OCR
23
- recognizeText, inferIOSDevicePixelRatio,
30
+ recognizeText, inferIOSDevicePixelRatio, enrichScreenshotWithLayout,
24
31
  // Android
25
32
  listAndroidDevices, androidScreenshot, androidInstallApp, androidLaunchApp, androidListPackages,
26
33
  // Android UI Input (Phase 2)
@@ -31,19 +38,55 @@ androidDescribeAll, androidDescribePoint,
31
38
  androidFindElement, androidWaitForElement,
32
39
  // iOS
33
40
  listIOSSimulators, iosScreenshot, iosInstallApp, iosLaunchApp, iosOpenUrl, iosTerminateApp, iosBootSimulator,
34
- // iOS IDB-based UI tools
35
- iosTap, iosSwipe, iosInputText, iosButton, iosKeyEvent, iosKeySequence, iosDescribeAll, iosDescribePoint, IOS_BUTTON_TYPES,
41
+ // iOS UI driver tools
42
+ isUiDriverAvailable, iosTap, iosSwipe, iosInputText, iosButton, iosKeyEvent, iosKeySequence, iosDescribeAll, iosDescribePoint, IOS_BUTTON_TYPES,
36
43
  // iOS Element Finding (no screenshots)
37
44
  iosFindElement, iosWaitForElement,
38
- // Debug HTTP Server
39
- startDebugHttpServer, getDebugServerPort,
40
45
  // Telemetry
41
46
  initTelemetry, trackToolInvocation, getTargetPlatform,
42
47
  // Format utilities (TONL)
43
- formatLogsAsTonl, formatNetworkAsTonl } from "./core/index.js";
48
+ formatLogsAsTonl, formatNetworkAsTonl,
49
+ // LogBox detection & control
50
+ detectLogBox, formatLogBoxWarning, dismissLogBox, formatDismissedEntries, pushLogBox, addLogBoxIgnorePatterns, verifyLogPipeline, formatIssueBody, buildGitHubUrl, shouldShowFeedbackHint, markFeedbackHintShown, } from "./core/index.js";
51
+ // Helper: resolve log buffer for a device (or create a merged buffer from all devices)
52
+ function resolveLogBuffer(device) {
53
+ if (device) {
54
+ const app = getConnectedAppByDevice(device);
55
+ if (!app)
56
+ throw new Error(`No connected device matches "${device}"`);
57
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
58
+ return getLogBuffer(deviceName);
59
+ }
60
+ // Merge all logs into a temporary buffer for read operations
61
+ const merged = new LogBuffer(5000);
62
+ for (const buffer of logBuffers.values()) {
63
+ for (const entry of buffer.getAll()) {
64
+ merged.add(entry);
65
+ }
66
+ }
67
+ return merged;
68
+ }
69
+ // Helper: resolve network buffer for a device (or create a merged buffer from all devices)
70
+ function resolveNetworkBuffer(device) {
71
+ if (device) {
72
+ const app = getConnectedAppByDevice(device);
73
+ if (!app)
74
+ throw new Error(`No connected device matches "${device}"`);
75
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
76
+ return getNetworkBuffer(deviceName);
77
+ }
78
+ // Merge all network requests into a temporary buffer for read operations
79
+ const merged = new NetworkBuffer(5000);
80
+ for (const buffer of networkBuffers.values()) {
81
+ for (const req of buffer.getAll({})) {
82
+ merged.set(`${Math.random()}`, req);
83
+ }
84
+ }
85
+ return merged;
86
+ }
44
87
  // Create MCP server
45
88
  const server = new McpServer({
46
- name: "react-native-ai-debugger",
89
+ name: "react-native-ai-devtools",
47
90
  version: "1.0.0"
48
91
  }, {
49
92
  instructions: "React Native debugging MCP server. Call get_usage_guide to learn recommended workflows for all tools. Quick start: scan_metro → get_logs / search_logs (console debugging) → ios_screenshot → tap(text=\"Submit\") or tap(x, y) (interact with UI)."
@@ -112,18 +155,56 @@ function estimateImageTokens(base64Data) {
112
155
  return Math.ceil(base64Data.length / 4); // fallback
113
156
  }
114
157
  }
158
+ // Tools that do NOT require an active Metro connection — excluded from feedback hint trigger
159
+ const NON_METRO_TOOLS = new Set([
160
+ "scan_metro",
161
+ "connect_metro",
162
+ "disconnect_metro",
163
+ "ensure_connection",
164
+ "get_connection_status",
165
+ "get_license_status",
166
+ "activate_license",
167
+ "delete_account",
168
+ "get_usage_guide",
169
+ "get_apps",
170
+ "list_ios_simulators",
171
+ "list_android_devices",
172
+ "ios_boot_simulator",
173
+ "ios_launch_app",
174
+ "android_launch_app",
175
+ "ios_install_app",
176
+ "android_install_app",
177
+ "send_feedback"
178
+ ]);
115
179
  // Registry for dev meta-tool — stores handlers and configs for dynamic dispatch
116
180
  /* eslint-disable @typescript-eslint/no-explicit-any */
117
181
  const toolRegistry = new Map();
118
- function registerToolWithTelemetry(toolName, config, handler) {
182
+ function registerToolWithTelemetry(toolName, config, handler, emptyResultDetector) {
119
183
  toolRegistry.set(toolName, { config, handler });
120
184
  server.registerTool(toolName, config, async (args) => {
185
+ // --- Usage limit check (runs on every tool call) ---
186
+ const usageInfo = getUsageInfo();
187
+ if (usageInfo && !usageInfo.canUse) {
188
+ const isCredits = usageInfo.creditsRemaining !== null;
189
+ const dashboardUrl = `${API_BASE_URL}/dashboard/usage`;
190
+ const message = isCredits
191
+ ? `Credits depleted. Purchase more at ${dashboardUrl}`
192
+ : `Monthly free limit reached (${usageInfo.used}/${usageInfo.limit}). Purchase a credits pack for unlimited usage at ${dashboardUrl}`;
193
+ return { content: [{ type: "text", text: message }] };
194
+ }
121
195
  const startTime = Date.now();
122
196
  let success = true;
123
197
  let errorMessage;
124
198
  let errorContext;
125
199
  let inputTokens;
126
200
  let outputTokens;
201
+ let emptyResult;
202
+ let meaningful;
203
+ let changeRate;
204
+ let tapStrategy;
205
+ let iosDriver;
206
+ let responsePreview;
207
+ let emptyReason;
127
208
  try {
128
209
  inputTokens = Math.ceil(JSON.stringify(args).length / 4);
129
210
  }
@@ -135,10 +216,31 @@ function registerToolWithTelemetry(toolName, config, handler) {
135
216
  // Check if result indicates an error
136
217
  if (result?.isError) {
137
218
  success = false;
138
- errorMessage = result.content?.[0]?.text || "Unknown error";
219
+ // Prefer concise _errorMessage over full response text (which may be large JSON)
220
+ errorMessage = result._errorMessage || result.content?.[0]?.text || "Unknown error";
139
221
  // Extract error context if provided (e.g., the expression that caused a syntax error)
140
222
  errorContext = result._errorContext;
141
223
  }
224
+ // Check for empty result (only on success, only if detector provided)
225
+ if (success && emptyResultDetector) {
226
+ try {
227
+ emptyResult = emptyResultDetector(result);
228
+ }
229
+ catch {
230
+ // Detector failure should never affect tool execution
231
+ }
232
+ }
233
+ // Extract meaningfulness data if provided (tap tool verification)
234
+ if (result?._meaningful !== undefined)
235
+ meaningful = result._meaningful;
236
+ if (result?._changeRate !== undefined)
237
+ changeRate = result._changeRate;
238
+ if (result?._tapStrategy)
239
+ tapStrategy = result._tapStrategy;
240
+ if (result?._iosDriver)
241
+ iosDriver = result._iosDriver;
242
+ if (result?._emptyReason)
243
+ emptyReason = result._emptyReason;
142
244
  if (Array.isArray(result?.content)) {
143
245
  let totalTokens = 0;
144
246
  for (const item of result.content) {
@@ -152,16 +254,52 @@ function registerToolWithTelemetry(toolName, config, handler) {
152
254
  if (totalTokens > 0)
153
255
  outputTokens = totalTokens;
154
256
  }
257
+ // Capture response text preview for local dev dashboard
258
+ if (Array.isArray(result?.content)) {
259
+ const textParts = result.content
260
+ .filter((item) => item.type === "text")
261
+ .map((item) => item.text);
262
+ if (textParts.length > 0) {
263
+ responsePreview = textParts.join("\n").substring(0, 2000);
264
+ }
265
+ }
266
+ // First-install feedback hint — fires once on first successful Metro-connected tool
267
+ if (!NON_METRO_TOOLS.has(toolName) && shouldShowFeedbackHint()) {
268
+ markFeedbackHintShown();
269
+ // Fire-and-forget — don't block the tool response
270
+ pushLogBox("Congratulations on your first tool call! If you encounter any issues or have ideas for improvement, ask your AI assistant to call send_feedback. Your feedback helps me make this product better for everyone. Best regards, rn-ai-devtools developer.", "warning", true, "logbox").catch(() => {
271
+ // Non-fatal — hint delivery failure should not affect tool execution
272
+ });
273
+ }
155
274
  return result;
156
275
  }
157
276
  catch (error) {
158
277
  success = false;
159
278
  errorMessage = error instanceof Error ? error.message : String(error);
279
+ getPostHogClient()?.captureException(error, getInstallationId(), { tool: toolName, server_version: getServerVersion() });
160
280
  throw error;
161
281
  }
162
282
  finally {
163
283
  const duration = Date.now() - startTime;
164
- trackToolInvocation(toolName, success, duration, errorMessage, errorContext, inputTokens, outputTokens, getTargetPlatform());
284
+ trackToolInvocation(toolName, success, duration, errorMessage, errorContext, inputTokens, outputTokens, getTargetPlatform(), emptyResult, meaningful, changeRate, tapStrategy, iosDriver, responsePreview, emptyReason);
285
+ getPostHogClient()?.capture({
286
+ distinctId: getInstallationId(),
287
+ event: toolName,
288
+ properties: {
289
+ success,
290
+ duration,
291
+ server_version: getServerVersion(),
292
+ ...(errorMessage && { error: errorMessage.substring(0, 200) }),
293
+ ...(errorMessage && { error_category: categorizeError(errorMessage) }),
294
+ ...(getTargetPlatform() && { platform: getTargetPlatform() }),
295
+ ...(tapStrategy && { tap_strategy: tapStrategy }),
296
+ ...(meaningful !== undefined && { meaningful }),
297
+ ...(changeRate !== undefined && { change_rate: changeRate }),
298
+ ...(iosDriver && { ios_driver: iosDriver }),
299
+ ...(emptyResult !== undefined && { empty_result: emptyResult }),
300
+ ...(emptyReason && { empty_reason: emptyReason }),
301
+ },
302
+ });
165
303
  }
166
304
  });
167
305
  }
@@ -219,33 +357,105 @@ registerToolWithTelemetry("scan_metro", {
219
357
  ]
220
358
  };
221
359
  }
222
- // Fetch devices from each port and connect
223
- const results = [];
360
+ // Phase 1: Fetch devices from all ports first
361
+ const portDevices = new Map();
224
362
  for (const port of openPorts) {
225
363
  const devices = await fetchDevices(port);
226
- if (devices.length === 0) {
227
- results.push(`Port ${port}: No devices found`);
364
+ const debuggable = filterDebuggableDevices(devices);
365
+ if (debuggable.length > 0) {
366
+ portDevices.set(port, debuggable);
367
+ }
368
+ }
369
+ // Phase 1.5: Purge stale connections for scanned ports
370
+ // When Metro restarts with a different app, device IDs change.
371
+ // Old connections remain in connectedApps — remove them before connecting new devices.
372
+ const purged = purgeStaleConnectionsForPorts(portDevices);
373
+ // Phase 2: Assign each device to the best port
374
+ // If a device appears on multiple ports, prefer the port where it has
375
+ // the fewest OTHER unique devices (i.e. its dedicated Metro server)
376
+ const devicePortAssignment = new Map(); // deviceName -> best port
377
+ const allDeviceNames = new Map();
378
+ for (const [port, devices] of portDevices) {
379
+ for (const device of devices) {
380
+ const name = device.deviceName || device.title;
381
+ const entry = allDeviceNames.get(name);
382
+ if (entry) {
383
+ entry.ports.push(port);
384
+ }
385
+ else {
386
+ allDeviceNames.set(name, { device, ports: [port] });
387
+ }
388
+ }
389
+ }
390
+ for (const [name, { ports }] of allDeviceNames) {
391
+ if (ports.length === 1) {
392
+ devicePortAssignment.set(name, ports[0]);
393
+ }
394
+ else {
395
+ // Prefer the port with fewer OTHER unique devices (the dedicated Metro)
396
+ let bestPort = ports[0];
397
+ let fewestOthers = Infinity;
398
+ for (const port of ports) {
399
+ const othersOnPort = (portDevices.get(port) || [])
400
+ .filter(d => (d.deviceName || d.title) !== name).length;
401
+ if (othersOnPort < fewestOthers) {
402
+ fewestOthers = othersOnPort;
403
+ bestPort = port;
404
+ }
405
+ }
406
+ devicePortAssignment.set(name, bestPort);
407
+ }
408
+ }
409
+ // Phase 3: Connect devices to their assigned ports
410
+ const results = [];
411
+ if (purged.length > 0) {
412
+ results.push(`Purged ${purged.length} stale connection(s): ${purged.join(", ")}`);
413
+ }
414
+ for (const port of openPorts) {
415
+ const devices = portDevices.get(port);
416
+ if (!devices) {
417
+ results.push(`Port ${port}: No debuggable devices found`);
228
418
  continue;
229
419
  }
230
420
  results.push(`Port ${port}: Found ${devices.length} device(s)`);
231
- const mainDevice = selectMainDevice(devices);
232
- if (mainDevice) {
421
+ for (const device of devices) {
422
+ const name = device.deviceName || device.title;
423
+ const assignedPort = devicePortAssignment.get(name);
424
+ if (assignedPort !== port) {
425
+ results.push(` - ${name}: Skipped (assigned to port ${assignedPort})`);
426
+ continue;
427
+ }
233
428
  try {
234
- const connectionResult = await connectToDevice(mainDevice, port);
429
+ const connectionResult = await connectToDevice(device, port);
235
430
  results.push(` - ${connectionResult}`);
236
- // Also connect to Metro build events for this port
237
- try {
238
- await connectMetroBuildEvents(port);
239
- results.push(` - Connected to Metro build events`);
240
- }
241
- catch {
242
- // Build events connection is optional, don't fail the scan
243
- }
244
431
  }
245
432
  catch (error) {
246
- results.push(` - Failed: ${error}`);
433
+ results.push(` - ${name}: Failed - ${error}`);
247
434
  }
248
435
  }
436
+ // Connect to Metro build events for this port
437
+ try {
438
+ await connectMetroBuildEvents(port);
439
+ results.push(` - Connected to Metro build events`);
440
+ }
441
+ catch {
442
+ // Build events connection is optional
443
+ }
444
+ }
445
+ // Proactive check: warn if iOS UI driver is missing
446
+ const hasIosDevice = Array.from(allDeviceNames.values()).some(({ device }) => {
447
+ const title = (device.title || "").toLowerCase();
448
+ return title.includes("iphone") || title.includes("ipad") || title.includes("ios");
449
+ });
450
+ if (hasIosDevice) {
451
+ const uiDriverOk = await isUiDriverAvailable();
452
+ if (!uiDriverOk) {
453
+ results.push("");
454
+ results.push("⚠️ WARNING: iOS UI driver is NOT installed. Tools like tap, ios_swipe, ios_input_text, ios_describe_all, and ios_find_element will fail.");
455
+ results.push(" Install the recommended driver: brew install cameroncooke/axe/axe");
456
+ results.push(" Then set IOS_DRIVER=axe in your MCP server environment.");
457
+ results.push(" Alternative: brew install idb-companion (used by default)");
458
+ }
249
459
  }
250
460
  return {
251
461
  content: [
@@ -261,33 +471,41 @@ registerToolWithTelemetry("get_apps", {
261
471
  description: "List currently connected React Native apps and their connection status. If no apps are connected, run scan_metro first to establish a connection.",
262
472
  inputSchema: {}
263
473
  }, async () => {
264
- const connections = getConnectedApps();
265
- if (connections.length === 0) {
474
+ const apps = getConnectedApps();
475
+ if (apps.length === 0) {
266
476
  return {
267
477
  content: [
268
478
  {
269
479
  type: "text",
270
- text: 'No apps connected. Run "scan_metro" first to discover and connect to running apps.'
480
+ text: "No connected devices. Run scan_metro to discover and connect to Metro servers."
271
481
  }
272
482
  ]
273
483
  };
274
484
  }
275
- const status = connections.map(({ app, isConnected }) => {
276
- const state = isConnected ? "Connected" : "Disconnected";
277
- return `${app.deviceInfo.title} (${app.deviceInfo.deviceName}): ${state}`;
485
+ const deviceLines = apps
486
+ .filter(({ isConnected }) => isConnected)
487
+ .map(({ app }, i) => {
488
+ const name = app.deviceInfo.deviceName || app.deviceInfo.title;
489
+ const appId = app.deviceInfo.appId || app.deviceInfo.title.split(" (")[0] || "unknown";
490
+ const lines = [` ${i + 1}. ${name} — ${appId} (${app.platform}, port ${app.port})`];
491
+ if (app.appDetection) {
492
+ const d = app.appDetection;
493
+ const version = d.reactNativeVersion !== "unknown" ? `RN ${d.reactNativeVersion}` : "RN unknown";
494
+ const expo = d.expoSdkVersion ? `, Expo SDK ${d.expoSdkVersion}` : "";
495
+ lines.push(` Environment: ${version}, ${d.architecture} arch, ${d.jsEngine}, ${app.platform} ${d.osVersion}${expo}`);
496
+ }
497
+ return lines.join("\n");
278
498
  });
279
- // Include active iOS simulator info if available
280
- const activeSimulatorUdid = getActiveSimulatorUdid();
281
- const simulatorInfo = activeSimulatorUdid
282
- ? `\nActive iOS Simulator (auto-scoped): ${activeSimulatorUdid}`
283
- : "\nNo iOS simulator linked (iOS tools will use first booted simulator)";
499
+ const text = [
500
+ `Connected devices:`,
501
+ ...deviceLines,
502
+ ``,
503
+ `Use device="${apps[0].app.deviceInfo.deviceName}" to target a specific device.`,
504
+ ``,
505
+ `Total logs in buffer: ${getTotalLogCount()}`
506
+ ].join("\n");
284
507
  return {
285
- content: [
286
- {
287
- type: "text",
288
- text: `Connected apps:\n${status.join("\n")}${simulatorInfo}\n\nTotal logs in buffer: ${logBuffer.size}`
289
- }
290
- ]
508
+ content: [{ type: "text", text }]
291
509
  };
292
510
  });
293
511
  // Tool: Get connection status (detailed health and gap tracking)
@@ -370,6 +588,99 @@ registerToolWithTelemetry("get_connection_status", {
370
588
  content: [{ type: "text", text: lines.join("\n") }]
371
589
  };
372
590
  });
591
+ // Tool: License status
592
+ registerToolWithTelemetry("get_license_status", {
593
+ description: "Get your installation ID and license status. Shows your unique Installation ID (needed to activate Pro in the dashboard), current license tier, and cache validity.",
594
+ inputSchema: {},
595
+ }, async () => {
596
+ const status = getLicenseStatus();
597
+ const lines = [];
598
+ lines.push(`Installation ID: ${status.installationId}`);
599
+ lines.push(`License: ${status.tier.charAt(0).toUpperCase() + status.tier.slice(1)}`);
600
+ if (status.plan) {
601
+ lines.push(`Plan expires: ${status.plan.expiresAt}`);
602
+ }
603
+ lines.push(`Cache valid until: ${status.cacheExpiresAt}`);
604
+ // Usage limits
605
+ const usage = getUsageInfo();
606
+ if (usage) {
607
+ lines.push("");
608
+ lines.push("--- Usage ---");
609
+ if (usage.creditsRemaining !== null) {
610
+ lines.push(`Credits remaining: ${usage.creditsRemaining.toLocaleString()}`);
611
+ lines.push(`Used this month: ${usage.used.toLocaleString()}`);
612
+ }
613
+ else {
614
+ lines.push(`Monthly usage: ${usage.used} / ${usage.limit}`);
615
+ }
616
+ lines.push(`Month: ${usage.monthKey}`);
617
+ if (usage.promotionalPeriod) {
618
+ const endsAt = usage.promotionalPeriodEndsAt
619
+ ? new Date(usage.promotionalPeriodEndsAt).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })
620
+ : "unknown";
621
+ lines.push(`Status: Active (promotional period — no limits until ${endsAt})`);
622
+ }
623
+ else {
624
+ lines.push(`Status: ${usage.canUse ? "Active" : "Limit reached"}`);
625
+ }
626
+ }
627
+ if (status.tier === "free") {
628
+ const dashboardUrl = getDashboardUrl();
629
+ lines.push("");
630
+ if (dashboardUrl) {
631
+ lines.push(`Link your account: ${dashboardUrl}/link?id=${status.installationId}`);
632
+ }
633
+ }
634
+ return {
635
+ content: [{ type: "text", text: lines.join("\n") }],
636
+ };
637
+ });
638
+ // Tool: Send feedback / bug report / feature request
639
+ registerToolWithTelemetry("send_feedback", {
640
+ description: "Create a feedback report (bug, feature request, or general feedback) for the react-native-ai-devtools team. " +
641
+ "Auto-collects environment info. Returns a pre-filled GitHub issue URL and formatted issue body. " +
642
+ "Ask the user to open the URL and paste the body to submit.",
643
+ inputSchema: {
644
+ type: z.enum(["feedback", "feature_request", "bug"]).describe('Type of feedback: "feedback", "feature_request", or "bug"'),
645
+ title: z.string().describe("Short summary (becomes the GitHub issue title)"),
646
+ description: z.string().describe("Detailed description of the feedback, feature request, or bug"),
647
+ workflow_context: z.string().optional().describe("What the user was trying to accomplish when they decided to give feedback")
648
+ }
649
+ }, async ({ type, title, description, workflow_context }) => {
650
+ // Collect environment info
651
+ const serverVersion = getServerVersion();
652
+ const platform = process.platform;
653
+ const deviceType = getTargetPlatform();
654
+ const licenseStatus = getLicenseStatus();
655
+ const env = {
656
+ serverVersion,
657
+ platform,
658
+ deviceType,
659
+ licenseTier: licenseStatus.tier
660
+ };
661
+ const input = { type, title, description, workflowContext: workflow_context };
662
+ const issueBody = formatIssueBody(input, env);
663
+ const githubUrl = buildGitHubUrl(title, type);
664
+ const output = [
665
+ "## Feedback Report Ready",
666
+ "",
667
+ `**GitHub Issue URL:** ${githubUrl}`,
668
+ "",
669
+ "**Issue body to paste:**",
670
+ "",
671
+ "```markdown",
672
+ issueBody,
673
+ "```",
674
+ "",
675
+ "Please ask the user to:",
676
+ "1. Open the GitHub URL above",
677
+ "2. Paste the issue body into the description field",
678
+ "3. Review and submit the issue"
679
+ ].join("\n");
680
+ return {
681
+ content: [{ type: "text", text: output }]
682
+ };
683
+ });
373
684
  // Tool: Ensure connection health
374
685
  registerToolWithTelemetry("ensure_connection", {
375
686
  description: "Verify or establish a healthy connection to a React Native app. Use before running commands if connection may be stale, or after navigation/reload. This tool runs a health check and will auto-reconnect if needed.",
@@ -393,7 +704,7 @@ registerToolWithTelemetry("ensure_connection", {
393
704
  content: [
394
705
  {
395
706
  type: "text",
396
- text: result.error || "Connection failed: Unknown error"
707
+ text: result.error || "Connection failed: No error details available. Try running scan_metro to check if Metro is running, then ensure_connection with forceRefresh=true."
397
708
  }
398
709
  ],
399
710
  isError: true
@@ -455,16 +766,71 @@ registerToolWithTelemetry("get_logs", {
455
766
  .boolean()
456
767
  .optional()
457
768
  .default(false)
458
- .describe("Return summary statistics instead of full logs (count by level + last 5 messages). Use for quick overview.")
769
+ .describe("Return summary statistics instead of full logs (count by level + last 5 messages). Use for quick overview."),
770
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
771
+ }
772
+ }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, format, summary, device }) => {
773
+ // Check if SDK is installed — prefer SDK data for richer logs
774
+ const sdkAvailable = await isSDKInstalled();
775
+ if (sdkAvailable) {
776
+ if (summary) {
777
+ const sdkStats = await getSDKConsoleStats();
778
+ if (sdkStats.success) {
779
+ const s = sdkStats.data;
780
+ const lines = [];
781
+ lines.push(`Total logs: ${s.total}`);
782
+ if (s.byLevel && Object.keys(s.byLevel).length > 0) {
783
+ lines.push("\nBy Level:");
784
+ for (const [lvl, cnt] of Object.entries(s.byLevel))
785
+ lines.push(` ${lvl}: ${cnt}`);
786
+ }
787
+ return { content: [{ type: "text", text: `Log Summary (SDK):\n\n${lines.join("\n")}` }] };
788
+ }
789
+ }
790
+ const sdkResult = await querySDKConsole({ count: maxLogs, level, text: startFromText });
791
+ if (sdkResult.success) {
792
+ const entries = sdkResult.data;
793
+ if (entries.length === 0) {
794
+ return { content: [{ type: "text", text: "No console logs captured yet." }] };
795
+ }
796
+ if (format === "tonl") {
797
+ const tonlLines = entries.map((e) => {
798
+ const time = new Date(e.timestamp).toLocaleTimeString();
799
+ let msg = e.message;
800
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
801
+ msg = msg.slice(0, maxMessageLength) + "...";
802
+ }
803
+ return `${time} [${e.level}] ${msg}`;
804
+ });
805
+ return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${tonlLines.join("\n")}` }] };
806
+ }
807
+ const lines = entries.map((e) => {
808
+ const time = new Date(e.timestamp).toLocaleTimeString();
809
+ let msg = e.message;
810
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
811
+ msg = msg.slice(0, maxMessageLength) + "...";
812
+ }
813
+ return `[${time}] [${e.level.toUpperCase()}] ${msg}`;
814
+ });
815
+ return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
816
+ }
459
817
  }
460
- }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, format, summary }) => {
461
818
  // Return summary if requested
462
819
  if (summary) {
463
- const summaryText = getLogSummary(logBuffer, { lastN: 5, maxMessageLength: 100 });
820
+ const summaryText = getLogSummary(resolveLogBuffer(device), { lastN: 5, maxMessageLength: 100 });
464
821
  let connectionWarning = "";
465
- if (logBuffer.size === 0) {
466
- const status = await checkAndEnsureConnection();
822
+ if (getTotalLogCount() === 0) {
823
+ const status = await checkAndEnsureConnection(device);
467
824
  connectionWarning = status.message ? `\n\n${status.message}` : "";
825
+ if (status.connected) {
826
+ const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
827
+ if (targetApp) {
828
+ const pipeline = await verifyLogPipeline(targetApp);
829
+ if (pipeline.message) {
830
+ connectionWarning += `\n\n${pipeline.message}`;
831
+ }
832
+ }
833
+ }
468
834
  }
469
835
  return {
470
836
  content: [
@@ -475,7 +841,7 @@ registerToolWithTelemetry("get_logs", {
475
841
  ]
476
842
  };
477
843
  }
478
- const { logs, count, formatted } = getLogs(logBuffer, {
844
+ const { logs, count, formatted } = getLogs(resolveLogBuffer(device), {
479
845
  maxLogs,
480
846
  level,
481
847
  startFromText,
@@ -484,9 +850,60 @@ registerToolWithTelemetry("get_logs", {
484
850
  });
485
851
  // Check connection health
486
852
  let connectionWarning = "";
853
+ let emptyReason;
487
854
  if (count === 0) {
488
- const status = await checkAndEnsureConnection();
855
+ const status = await checkAndEnsureConnection(device);
489
856
  connectionWarning = status.message ? `\n\n${status.message}` : "";
857
+ // Track empty reason for telemetry
858
+ emptyReason = "no_logs";
859
+ if (!status.connected) {
860
+ emptyReason = "disconnected";
861
+ }
862
+ else if (status.wasReconnected) {
863
+ emptyReason = "post_reconnect";
864
+ }
865
+ // End-to-end log pipeline verification (with automatic recovery)
866
+ if (status.connected) {
867
+ const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
868
+ if (targetApp) {
869
+ const pipeline = await verifyLogPipeline(targetApp);
870
+ if (pipeline.message) {
871
+ connectionWarning += `\n\n${pipeline.message}`;
872
+ }
873
+ if (!pipeline.ok) {
874
+ emptyReason = "pipeline_failed";
875
+ }
876
+ else if (pipeline.recovered) {
877
+ emptyReason = "pipeline_recovered";
878
+ }
879
+ // If pipeline recovered, re-read the buffer — new logs may have arrived
880
+ if (pipeline.recovered && pipeline.ok) {
881
+ const retryResult = getLogs(resolveLogBuffer(device), {
882
+ maxLogs, level, startFromText, maxMessageLength, verbose
883
+ });
884
+ if (retryResult.count > 0) {
885
+ // Return the recovered logs instead of empty
886
+ return {
887
+ _emptyReason: "pipeline_recovered",
888
+ content: [{
889
+ type: "text",
890
+ text: `React Native Console Logs (${retryResult.count} entries):\n\n${retryResult.formatted}${connectionWarning}`
891
+ }]
892
+ };
893
+ }
894
+ }
895
+ }
896
+ }
897
+ // Add diagnostic metadata for empty results (captured by telemetry via responsePreview)
898
+ if (count === 0) {
899
+ const diagParts = [
900
+ `empty_reason=${status?.wasReconnected ? "post_reconnect" : "no_logs"}`,
901
+ `connection=${getPassiveConnectionStatus().reason}`,
902
+ `device_count=${connectedApps.size}`,
903
+ `buffer_sizes=${JSON.stringify(Object.fromEntries([...logBuffers.entries()].map(([k, v]) => [k, v.size])))}`,
904
+ ];
905
+ connectionWarning += `\n\n[DIAG] ${diagParts.join(", ")}`;
906
+ }
490
907
  }
491
908
  else {
492
909
  const passive = getPassiveConnectionStatus();
@@ -514,6 +931,7 @@ registerToolWithTelemetry("get_logs", {
514
931
  if (format === "tonl") {
515
932
  const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
516
933
  return {
934
+ ...(emptyReason && { _emptyReason: emptyReason }),
517
935
  content: [
518
936
  {
519
937
  type: "text",
@@ -523,6 +941,7 @@ registerToolWithTelemetry("get_logs", {
523
941
  };
524
942
  }
525
943
  return {
944
+ ...(emptyReason && { _emptyReason: emptyReason }),
526
945
  content: [
527
946
  {
528
947
  type: "text",
@@ -530,7 +949,9 @@ registerToolWithTelemetry("get_logs", {
530
949
  }
531
950
  ]
532
951
  };
533
- });
952
+ },
953
+ // Empty result detector: buffer has no entries at all
954
+ () => getTotalLogCount() === 0);
534
955
  // Tool: Search logs
535
956
  registerToolWithTelemetry("search_logs", {
536
957
  description: "Search console logs for text (case-insensitive)",
@@ -551,14 +972,46 @@ registerToolWithTelemetry("search_logs", {
551
972
  .enum(["text", "tonl"])
552
973
  .optional()
553
974
  .default("tonl")
554
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)")
975
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)"),
976
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
977
+ }
978
+ }, async ({ text, maxResults, maxMessageLength, verbose, format, device }) => {
979
+ // Check if SDK is installed — prefer SDK data
980
+ const sdkAvailable = await isSDKInstalled();
981
+ if (sdkAvailable) {
982
+ const sdkResult = await querySDKConsole({ count: maxResults, text });
983
+ if (sdkResult.success) {
984
+ const entries = sdkResult.data;
985
+ if (entries.length === 0) {
986
+ return { content: [{ type: "text", text: `No logs matching "${text}" found.` }] };
987
+ }
988
+ if (format === "tonl") {
989
+ const tonlLines = entries.map((e) => {
990
+ const time = new Date(e.timestamp).toLocaleTimeString();
991
+ let msg = e.message;
992
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
993
+ msg = msg.slice(0, maxMessageLength) + "...";
994
+ }
995
+ return `${time} [${e.level}] ${msg}`;
996
+ });
997
+ return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${tonlLines.join("\n")}` }] };
998
+ }
999
+ const lines = entries.map((e) => {
1000
+ const time = new Date(e.timestamp).toLocaleTimeString();
1001
+ let msg = e.message;
1002
+ if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
1003
+ msg = msg.slice(0, maxMessageLength) + "...";
1004
+ }
1005
+ return `[${time}] [${e.level.toUpperCase()}] ${msg}`;
1006
+ });
1007
+ return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
1008
+ }
555
1009
  }
556
- }, async ({ text, maxResults, maxMessageLength, verbose, format }) => {
557
- const { logs, count, formatted } = searchLogs(logBuffer, text, { maxResults, maxMessageLength, verbose });
1010
+ const { logs, count, formatted } = searchLogs(resolveLogBuffer(device), text, { maxResults, maxMessageLength, verbose });
558
1011
  // Check connection health
559
1012
  let connectionWarning = "";
560
1013
  if (count === 0) {
561
- const status = await checkAndEnsureConnection();
1014
+ const status = await checkAndEnsureConnection(device);
562
1015
  connectionWarning = status.message ? `\n\n${status.message}` : "";
563
1016
  }
564
1017
  else {
@@ -591,17 +1044,32 @@ registerToolWithTelemetry("search_logs", {
591
1044
  // Tool: Clear logs
592
1045
  registerToolWithTelemetry("clear_logs", {
593
1046
  description: "Clear the log buffer",
594
- inputSchema: {}
595
- }, async () => {
596
- const count = logBuffer.clear();
597
- return {
598
- content: [
599
- {
600
- type: "text",
601
- text: `Cleared ${count} log entries from buffer.`
602
- }
603
- ]
604
- };
1047
+ inputSchema: {
1048
+ device: z.string().optional().describe("Target device name (substring match). Omit to clear all devices. Run get_apps to see connected devices.")
1049
+ }
1050
+ }, async ({ device }) => {
1051
+ if (device) {
1052
+ const app = getConnectedAppByDevice(device);
1053
+ if (!app)
1054
+ throw new Error(`No connected device matches "${device}"`);
1055
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
1056
+ const count = getLogBuffer(deviceName).clear();
1057
+ return { content: [{ type: "text", text: `Cleared ${count} log entries from ${deviceName}.` }] };
1058
+ }
1059
+ // Clear all
1060
+ let total = 0;
1061
+ for (const buffer of logBuffers.values()) {
1062
+ total += buffer.clear();
1063
+ }
1064
+ // Also clear SDK buffer if available
1065
+ const sdkAvailable = await isSDKInstalled();
1066
+ if (sdkAvailable) {
1067
+ const sdkResult = await clearSDKConsole();
1068
+ if (sdkResult.success && sdkResult.count) {
1069
+ total += sdkResult.count;
1070
+ }
1071
+ }
1072
+ return { content: [{ type: "text", text: `Cleared ${total} log entries from all devices.` }] };
605
1073
  });
606
1074
  // Tool: Connect to specific Metro port
607
1075
  registerToolWithTelemetry("connect_metro", {
@@ -662,9 +1130,11 @@ registerToolWithTelemetry("connect_metro", {
662
1130
  });
663
1131
  // Tool: Disconnect from Metro
664
1132
  registerToolWithTelemetry("disconnect_metro", {
665
- description: "Disconnect from all Metro servers and stop auto-reconnection. Use this when you want to switch to the built-in React Native debugger (which requires the CDP slot to be free). Log and network buffers are preserved. Reconnect later with scan_metro.",
666
- inputSchema: {}
667
- }, async () => {
1133
+ description: "Disconnect from Metro servers and stop auto-reconnection. Without device param: disconnects ALL devices. With device param: disconnects only the matching device. Use this to remove stale connections or free the CDP slot for the built-in debugger. Log and network buffers are preserved. Reconnect later with scan_metro.",
1134
+ inputSchema: {
1135
+ device: z.string().optional().describe("Target device name (substring match) to disconnect. Omit to disconnect all devices. Run get_apps to see connected devices.")
1136
+ }
1137
+ }, async ({ device }) => {
668
1138
  const connections = getConnectedApps();
669
1139
  if (connections.length === 0) {
670
1140
  return {
@@ -676,6 +1146,36 @@ registerToolWithTelemetry("disconnect_metro", {
676
1146
  ]
677
1147
  };
678
1148
  }
1149
+ // Targeted disconnect: only disconnect a specific device
1150
+ if (device) {
1151
+ const app = getConnectedAppByDevice(device);
1152
+ if (!app) {
1153
+ return {
1154
+ content: [{ type: "text", text: `No connected device matches "${device}". Run get_apps to see connected devices.` }],
1155
+ isError: true
1156
+ };
1157
+ }
1158
+ // Find and close the matching connection
1159
+ for (const [key, connectedApp] of connectedApps.entries()) {
1160
+ if (connectedApp.ws === app.ws) {
1161
+ // Suppress reconnection for this specific device
1162
+ suppressReconnectionForKey(key);
1163
+ cancelReconnectionTimer(key);
1164
+ try {
1165
+ connectedApp.ws.close();
1166
+ }
1167
+ catch {
1168
+ // Ignore close errors
1169
+ }
1170
+ connectedApps.delete(key);
1171
+ const name = connectedApp.deviceInfo.deviceName || connectedApp.deviceInfo.title;
1172
+ return {
1173
+ content: [{ type: "text", text: `Disconnected from ${name} (port ${connectedApp.port}). Buffers preserved. Use scan_metro to reconnect.` }]
1174
+ };
1175
+ }
1176
+ }
1177
+ }
1178
+ // Disconnect all
679
1179
  const disconnected = [];
680
1180
  // Suppress reconnection BEFORE closing sockets
681
1181
  // (close handlers fire async and would re-schedule reconnection)
@@ -696,6 +1196,7 @@ registerToolWithTelemetry("disconnect_metro", {
696
1196
  disconnectMetroBuildEvents();
697
1197
  // Clear connection state (but NOT log/network buffers)
698
1198
  clearAllConnectionState();
1199
+ clearAllCDPMessageTimes();
699
1200
  const lines = [
700
1201
  `Disconnected from ${disconnected.length} app(s):`,
701
1202
  ...disconnected.map((d) => ` - ${d}`),
@@ -737,10 +1238,11 @@ registerToolWithTelemetry("execute_in_app", {
737
1238
  .boolean()
738
1239
  .optional()
739
1240
  .default(false)
740
- .describe("Disable result truncation. Tip: Be cautious - Redux stores or large state can return 10KB+.")
1241
+ .describe("Disable result truncation. Tip: Be cautious - Redux stores or large state can return 10KB+."),
1242
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
741
1243
  }
742
- }, async ({ expression, awaitPromise, maxResultLength, verbose }) => {
743
- const result = await executeInApp(expression, awaitPromise);
1244
+ }, async ({ expression, awaitPromise, maxResultLength, verbose, device }) => {
1245
+ const result = await executeInApp(expression, awaitPromise, {}, device);
744
1246
  if (!result.success) {
745
1247
  let errorText = `Error: ${result.error}`;
746
1248
  // If the error is a ReferenceError (accessing a global that doesn't exist),
@@ -775,13 +1277,22 @@ registerToolWithTelemetry("execute_in_app", {
775
1277
  }
776
1278
  ]
777
1279
  };
1280
+ },
1281
+ // Empty result detector: successful execution but no meaningful output
1282
+ (result) => {
1283
+ if (result?.isError)
1284
+ return false;
1285
+ const text = result?.content?.[0]?.text;
1286
+ return text === undefined || text === "" || text === "undefined" || text === "null";
778
1287
  });
779
1288
  // Tool: List debug globals available in the app
780
1289
  registerToolWithTelemetry("list_debug_globals", {
781
1290
  description: "List globally available debugging objects in the connected React Native app (Apollo Client, Redux store, React DevTools, etc.). Use this to discover what state management and debugging tools are available.",
782
- inputSchema: {}
783
- }, async () => {
784
- const result = await listDebugGlobals();
1291
+ inputSchema: {
1292
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1293
+ }
1294
+ }, async ({ device }) => {
1295
+ const result = await listDebugGlobals(device);
785
1296
  if (!result.success) {
786
1297
  return {
787
1298
  content: [
@@ -808,10 +1319,11 @@ registerToolWithTelemetry("inspect_global", {
808
1319
  inputSchema: {
809
1320
  objectName: z
810
1321
  .string()
811
- .describe("Name of the global object to inspect (e.g., '__EXPO_ROUTER__', '__APOLLO_CLIENT__')")
1322
+ .describe("Name of the global object to inspect (e.g., '__EXPO_ROUTER__', '__APOLLO_CLIENT__')"),
1323
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
812
1324
  }
813
- }, async ({ objectName }) => {
814
- const result = await inspectGlobal(objectName);
1325
+ }, async ({ objectName, device }) => {
1326
+ const result = await inspectGlobal(objectName, device);
815
1327
  if (!result.success) {
816
1328
  let errorText = `Error: ${result.error}`;
817
1329
  // If the error is a ReferenceError (accessing a global that doesn't exist),
@@ -843,13 +1355,8 @@ registerToolWithTelemetry("inspect_global", {
843
1355
  // ============================================================================
844
1356
  // Tool: Get the React component tree
845
1357
  registerToolWithTelemetry("get_component_tree", {
846
- description: "Get the React component tree from the running app. **RECOMMENDED**: Use focusedOnly=true with structureOnly=true for a token-efficient overview of just the active screen (~1-2KB). This skips navigation wrappers and global overlays, showing only what's actually visible.",
1358
+ description: "Get the full React component tree from the running app. Shows the complete fiber hierarchy including providers, navigation wrappers, and internal components. For a screen overview with positions and text, use get_screen_layout instead. Use structureOnly=true for compact names-only output.",
847
1359
  inputSchema: {
848
- focusedOnly: z
849
- .boolean()
850
- .optional()
851
- .default(false)
852
- .describe("Return only the focused/active screen subtree, skipping navigation wrappers and overlays. Dramatically reduces output size. (Recommended: true)"),
853
1360
  structureOnly: z
854
1361
  .boolean()
855
1362
  .optional()
@@ -858,7 +1365,7 @@ registerToolWithTelemetry("get_component_tree", {
858
1365
  maxDepth: z
859
1366
  .number()
860
1367
  .optional()
861
- .describe("Maximum tree depth (default: 25 for focusedOnly+structureOnly, 40 for structureOnly, 100 for full mode)"),
1368
+ .describe("Maximum tree depth (default: 5000)"),
862
1369
  includeProps: z
863
1370
  .boolean()
864
1371
  .optional()
@@ -878,17 +1385,18 @@ registerToolWithTelemetry("get_component_tree", {
878
1385
  .enum(["json", "tonl"])
879
1386
  .optional()
880
1387
  .default("tonl")
881
- .describe("Output format: 'json' or 'tonl' (default, compact indented tree). Ignored if structureOnly=true.")
1388
+ .describe("Output format: 'json' or 'tonl' (default, compact indented tree). Ignored if structureOnly=true."),
1389
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
882
1390
  }
883
- }, async ({ focusedOnly, structureOnly, maxDepth, includeProps, includeStyles, hideInternals, format }) => {
1391
+ }, async ({ structureOnly, maxDepth, includeProps, includeStyles, hideInternals, format, device }) => {
884
1392
  const result = await getComponentTree({
885
- focusedOnly,
886
1393
  structureOnly,
887
1394
  maxDepth,
888
1395
  includeProps,
889
1396
  includeStyles,
890
1397
  hideInternals,
891
- format
1398
+ format,
1399
+ device
892
1400
  });
893
1401
  if (!result.success) {
894
1402
  return {
@@ -912,36 +1420,22 @@ registerToolWithTelemetry("get_component_tree", {
912
1420
  });
913
1421
  // Tool: Get full screen layout (all components with layout styles)
914
1422
  registerToolWithTelemetry("get_screen_layout", {
915
- description: "Get layout information for all components on screen. **USE AFTER get_component_tree**: First use get_component_tree(structureOnly=true) to understand structure, then use this tool OR find_components with includeLayout=true to get layout details for specific areas. This tool returns full layout data which can be large for complex screens.",
1423
+ description: "Get a screen map showing visible components as an indented tree with actual screen positions. Uses measureInWindow for real coordinates and filters out off-screen components. Returns meaningful component names with text content and frame data (x,y width x height). Coordinates are in **points** (iOS) or **dp** (Android) — NOT screenshot pixels. Use tap(text=...) or tap(testID=...) to interact with discovered components. Use extended=true to include layout styles (padding, margin, flex, backgroundColor, etc.).",
916
1424
  inputSchema: {
917
- maxDepth: z
918
- .number()
919
- .optional()
920
- .default(65)
921
- .describe("Maximum tree depth to traverse (default: 65, balanced for most screens)"),
922
- componentsOnly: z
1425
+ extended: z
923
1426
  .boolean()
924
1427
  .optional()
925
1428
  .default(false)
926
- .describe("Only show custom components, hide host components (View, Text, etc.)"),
927
- shortPath: z
928
- .boolean()
929
- .optional()
930
- .default(true)
931
- .describe("Show only last 3 path segments instead of full path (default: true)"),
1429
+ .describe("Include layout styles (padding, margin, flex, backgroundColor, borderRadius, etc.) for each component. Default: false for compact output."),
932
1430
  summary: z
933
1431
  .boolean()
934
1432
  .optional()
935
1433
  .default(false)
936
- .describe("Return only component counts by name instead of full element list (default: false)"),
937
- format: z
938
- .enum(["json", "tonl"])
939
- .optional()
940
- .default("tonl")
941
- .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)")
1434
+ .describe("Return only component counts by name instead of full tree (default: false)"),
1435
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
942
1436
  }
943
- }, async ({ maxDepth, componentsOnly, shortPath, summary, format }) => {
944
- const result = await getScreenLayout({ maxDepth, componentsOnly, shortPath, summary, format });
1437
+ }, async ({ extended, summary, device }) => {
1438
+ const result = await getScreenLayout({ extended, summary, device });
945
1439
  if (!result.success) {
946
1440
  return {
947
1441
  content: [
@@ -962,9 +1456,37 @@ registerToolWithTelemetry("get_screen_layout", {
962
1456
  ]
963
1457
  };
964
1458
  });
1459
+ // Tool: Get all pressable elements on screen
1460
+ registerToolWithTelemetry("get_pressable_elements", {
1461
+ description: "Find all pressable (onPress) and input (TextInput) elements currently visible on screen. Returns component names, tap-ready center coordinates (in points/dp), text labels, testID, and accessibilityLabel. Useful when you need to tap an icon or button but can't identify it from a screenshot alone. Each element includes hasLabel (true if it contains text) and isInput (true for TextInput fields).",
1462
+ inputSchema: {
1463
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1464
+ }
1465
+ }, async ({ device }) => {
1466
+ const result = await getPressableElements({ device });
1467
+ if (!result.success) {
1468
+ return {
1469
+ content: [
1470
+ {
1471
+ type: "text",
1472
+ text: `Error: ${result.error}`
1473
+ }
1474
+ ],
1475
+ isError: true
1476
+ };
1477
+ }
1478
+ return {
1479
+ content: [
1480
+ {
1481
+ type: "text",
1482
+ text: result.result || "No pressable elements found."
1483
+ }
1484
+ ]
1485
+ };
1486
+ });
965
1487
  // Tool: Inspect a specific component by name
966
1488
  registerToolWithTelemetry("inspect_component", {
967
- description: "Inspect a specific React component by name. **DRILL-DOWN TOOL**: Use after get_component_tree(structureOnly=true) to inspect specific components. Returns props, style, state (hooks), and optionally children tree. Use childrenDepth to control how deep nested children go.",
1489
+ description: "Inspect a specific React component by name. **DRILL-DOWN TOOL**: Use after get_screen_layout or find_components to identify which component to inspect. Returns props, style, state (hooks), and optionally children tree. Use childrenDepth to control how deep nested children go.",
968
1490
  inputSchema: {
969
1491
  componentName: z
970
1492
  .string()
@@ -990,16 +1512,18 @@ registerToolWithTelemetry("inspect_component", {
990
1512
  .boolean()
991
1513
  .optional()
992
1514
  .default(true)
993
- .describe("Simplify hooks output by hiding effects and reducing depth (default: true)")
1515
+ .describe("Simplify hooks output by hiding effects and reducing depth (default: true)"),
1516
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
994
1517
  }
995
- }, async ({ componentName, index, includeState, includeChildren, childrenDepth, shortPath, simplifyHooks }) => {
1518
+ }, async ({ componentName, index, includeState, includeChildren, childrenDepth, shortPath, simplifyHooks, device }) => {
996
1519
  const result = await inspectComponent(componentName, {
997
1520
  index,
998
1521
  includeState,
999
1522
  includeChildren,
1000
1523
  childrenDepth,
1001
1524
  shortPath,
1002
- simplifyHooks
1525
+ simplifyHooks,
1526
+ device
1003
1527
  });
1004
1528
  if (!result.success) {
1005
1529
  return {
@@ -1023,7 +1547,7 @@ registerToolWithTelemetry("inspect_component", {
1023
1547
  });
1024
1548
  // Tool: Find components matching a pattern
1025
1549
  registerToolWithTelemetry("find_components", {
1026
- description: "Find components matching a name pattern. **TARGETED SEARCH**: Use after get_component_tree(structureOnly=true) to find specific components by pattern and get their layout info. More efficient than get_screen_layout for targeted queries. Use includeLayout=true to get padding/margin/flex styles.",
1550
+ description: "Find components matching a name pattern. **TARGETED SEARCH**: Use after get_screen_layout or get_component_tree(structureOnly=true) to find specific components by pattern. Use includeLayout=true to get padding/margin/flex styles.",
1027
1551
  inputSchema: {
1028
1552
  pattern: z
1029
1553
  .string()
@@ -1044,10 +1568,11 @@ registerToolWithTelemetry("find_components", {
1044
1568
  .enum(["json", "tonl"])
1045
1569
  .optional()
1046
1570
  .default("tonl")
1047
- .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)")
1571
+ .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)"),
1572
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1048
1573
  }
1049
- }, async ({ pattern, maxResults, includeLayout, shortPath, summary, format }) => {
1050
- const result = await findComponents(pattern, { maxResults, includeLayout, shortPath, summary, format });
1574
+ }, async ({ pattern, maxResults, includeLayout, shortPath, summary, format, device }) => {
1575
+ const result = await findComponents(pattern, { maxResults, includeLayout, shortPath, summary, format, device });
1051
1576
  if (!result.success) {
1052
1577
  return {
1053
1578
  content: [
@@ -1071,13 +1596,18 @@ registerToolWithTelemetry("find_components", {
1071
1596
  // Tool: Unified tap — tries fiber, accessibility, OCR, coordinate strategies
1072
1597
  registerToolWithTelemetry("tap", {
1073
1598
  description: "Tap a UI element. Automatically tries multiple strategies: fiber tree (React), accessibility tree (native), and OCR (visual). " +
1074
- "Auto-detects platform (iOS/Android). For coordinates, accepts pixels from screenshot and converts internally.\n\n" +
1599
+ "Auto-detects platform (iOS/Android). For coordinates, pass raw pixel values from screenshot — tap handles all coordinate conversion internally (iOS pixel-to-point, scale factor adjustments).\n\n" +
1600
+ "PREREQUISITE: For iOS, an iOS UI driver must be installed — AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion). Without it, accessibility and coordinate strategies will fail.\n\n" +
1601
+ "IMPORTANT: When both iOS and Android devices are connected, specify platform explicitly to avoid tapping on the wrong device.\n\n" +
1075
1602
  "Examples:\n" +
1076
1603
  "- tap(text=\"Submit\") — finds and taps element with matching text\n" +
1077
1604
  "- tap(testID=\"login-btn\") — finds by testID\n" +
1078
1605
  "- tap(component=\"HamburgerIcon\") — finds by React component name\n" +
1079
- "- tap(x=300, y=600) — taps at pixel coordinates from screenshot\n" +
1080
- "- tap(text=\"Menu\", strategy=\"ocr\") — forces OCR strategy only",
1606
+ "- tap(x=300, y=600) — taps at pixel coordinates; use coordinates from the pressable elements list returned by ios_screenshot/android_screenshot for reliable icon and ambiguous-element taps\n" +
1607
+ "- tap(text=\"Menu\", strategy=\"ocr\") — forces OCR strategy only\n" +
1608
+ "- tap(x=300, y=600, native=true, platform=\"android\") — taps directly via ADB/simctl without React Native connection\n\n" +
1609
+ "Returns a post-tap screenshot by default (set screenshot=false to disable). " +
1610
+ "For coordinate/accessibility/OCR taps, automatically verifies the tap had a visual effect via before/after screenshot diff (set verify=false to disable).",
1081
1611
  inputSchema: {
1082
1612
  text: z
1083
1613
  .string()
@@ -1113,6 +1643,35 @@ registerToolWithTelemetry("tap", {
1113
1643
  .optional()
1114
1644
  .describe("Max parent levels to traverse when searching by component name (default: 15). " +
1115
1645
  "Increase if your component is deeply wrapped (e.g. inside multiple HOCs/animation wrappers)."),
1646
+ native: z
1647
+ .boolean()
1648
+ .optional()
1649
+ .default(false)
1650
+ .describe("When true, tap coordinates directly via ADB/simctl without requiring a React Native connection. " +
1651
+ "Useful for interacting with native UI, system dialogs, or non-RN apps. Requires x/y coordinates."),
1652
+ platform: z
1653
+ .enum(["ios", "android"])
1654
+ .optional()
1655
+ .describe("Target platform. Required when both iOS and Android devices are connected. Auto-detected if only one platform is available."),
1656
+ screenshot: z
1657
+ .boolean()
1658
+ .optional()
1659
+ .default(true)
1660
+ .describe("Capture and return a post-tap screenshot. Enabled by default. Set to false for fastest execution (no screenshots)."),
1661
+ verify: z
1662
+ .boolean()
1663
+ .optional()
1664
+ .describe("Run before/after screenshot diff to detect if the tap had a meaningful visual effect. " +
1665
+ "Default: true for coordinate/accessibility/ocr strategies, false for fiber. Requires screenshot=true."),
1666
+ burst: z
1667
+ .boolean()
1668
+ .optional()
1669
+ .default(false)
1670
+ .describe("Enable burst screenshot capture for enhanced verification. " +
1671
+ "Captures 4 rapid screenshots (~150ms intervals) after the tap to detect transient visual feedback " +
1672
+ "(press animations, highlights, ripples) that may settle before a standard after-screenshot. " +
1673
+ "Results are stored in the image buffer (use get_images to inspect individual frames). " +
1674
+ "Default: false."),
1116
1675
  },
1117
1676
  }, async (args) => {
1118
1677
  const result = await tap({
@@ -1124,19 +1683,51 @@ registerToolWithTelemetry("tap", {
1124
1683
  y: args.y,
1125
1684
  strategy: args.strategy,
1126
1685
  maxTraversalDepth: args.maxTraversalDepth,
1686
+ native: args.native,
1687
+ platform: args.platform,
1688
+ screenshot: args.screenshot,
1689
+ verify: args.verify,
1690
+ burst: args.burst,
1127
1691
  });
1128
- const text = JSON.stringify(result, null, 2);
1692
+ const { screenshot: screenshotData, ...resultWithoutScreenshot } = result;
1693
+ const text = JSON.stringify(resultWithoutScreenshot, null, 2);
1694
+ // Pack strategy mode + attempted strategies into errorContext for telemetry
1695
+ // e.g. "s=ocr|fiber:no_pressable|ocr:no_match" or "s=auto|fiber:no_pressable|accessibility:not_found|ocr:no_match"
1696
+ const stratPrefix = args.strategy && args.strategy !== "auto" ? `s=${args.strategy}|` : "";
1697
+ const errorContext = result.attempted?.length
1698
+ ? stratPrefix + result.attempted.map(a => `${a.strategy}:${a.reason.slice(0, 40)}`).join("|")
1699
+ : undefined;
1700
+ const content = [
1701
+ { type: "text", text },
1702
+ ];
1703
+ if (screenshotData) {
1704
+ content.push({
1705
+ type: "image",
1706
+ data: screenshotData.image,
1707
+ mimeType: "image/jpeg",
1708
+ });
1709
+ }
1129
1710
  return {
1130
- content: [{ type: "text", text }],
1711
+ content,
1131
1712
  isError: !result.success,
1713
+ _errorMessage: !result.success
1714
+ ? `${JSON.stringify(result.query)}|${result.error || ""}`
1715
+ : undefined,
1716
+ _errorContext: errorContext,
1717
+ _meaningful: result.verification?.meaningful,
1718
+ _changeRate: result.verification?.changeRate,
1719
+ _tapStrategy: result.method,
1720
+ _iosDriver: result.platform === "ios" ? (process.env.IOS_DRIVER?.toLowerCase() || "idb") : undefined,
1132
1721
  };
1133
1722
  });
1134
1723
  // Tool: Toggle Element Inspector programmatically
1135
1724
  registerToolWithTelemetry("toggle_element_inspector", {
1136
1725
  description: "Toggle React Native's Element Inspector overlay on/off. Rarely needed directly — get_inspector_selection auto-enables the inspector when called with coordinates. Use this only when you need manual control over the overlay visibility.",
1137
- inputSchema: {}
1138
- }, async () => {
1139
- const result = await toggleElementInspector();
1726
+ inputSchema: {
1727
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1728
+ }
1729
+ }, async ({ device }) => {
1730
+ const result = await toggleElementInspector(device);
1140
1731
  if (!result.success) {
1141
1732
  return {
1142
1733
  content: [
@@ -1192,21 +1783,22 @@ registerToolWithTelemetry("get_inspector_selection", {
1192
1783
  y: z
1193
1784
  .number()
1194
1785
  .optional()
1195
- .describe("Y coordinate (in points). If provided with x, auto-taps at this location.")
1786
+ .describe("Y coordinate (in points). If provided with x, auto-taps at this location."),
1787
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1196
1788
  }
1197
- }, async ({ x, y }) => {
1789
+ }, async ({ x, y, device }) => {
1198
1790
  // If coordinates provided, do the full flow: enable inspector -> tap -> read
1199
1791
  if (x !== undefined && y !== undefined) {
1200
1792
  // Check if inspector is active
1201
- const inspectorActive = await isInspectorActive();
1793
+ const inspectorActive = await isInspectorActive(device);
1202
1794
  // Enable inspector if not active
1203
1795
  if (!inspectorActive) {
1204
- await toggleElementInspector();
1796
+ await toggleElementInspector(device);
1205
1797
  // Wait for inspector to initialize
1206
1798
  await new Promise((resolve) => setTimeout(resolve, 300));
1207
1799
  }
1208
1800
  // Detect platform from connected app
1209
- const app = getFirstConnectedApp();
1801
+ const app = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
1210
1802
  if (!app) {
1211
1803
  return {
1212
1804
  content: [{ type: "text", text: "No app connected. Run scan_metro first." }],
@@ -1241,7 +1833,7 @@ registerToolWithTelemetry("get_inspector_selection", {
1241
1833
  await new Promise((resolve) => setTimeout(resolve, 200));
1242
1834
  }
1243
1835
  // Read the current selection
1244
- const result = await getInspectorSelection();
1836
+ const result = await getInspectorSelection(device);
1245
1837
  if (!result.success) {
1246
1838
  return {
1247
1839
  content: [{ type: "text", text: `Error: ${result.error}` }],
@@ -1295,10 +1887,11 @@ registerToolWithTelemetry("inspect_at_point", {
1295
1887
  .boolean()
1296
1888
  .optional()
1297
1889
  .default(true)
1298
- .describe("Include position/dimensions (frame) in the output (default: true)")
1890
+ .describe("Include position/dimensions (frame) in the output (default: true)"),
1891
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
1299
1892
  }
1300
- }, async ({ x, y, includeProps, includeFrame }) => {
1301
- const result = await inspectAtPoint(x, y, { includeProps, includeFrame });
1893
+ }, async ({ x, y, includeProps, includeFrame, device }) => {
1894
+ const result = await inspectAtPoint(x, y, { includeProps, includeFrame, device });
1302
1895
  if (!result.success) {
1303
1896
  return {
1304
1897
  content: [
@@ -1343,7 +1936,7 @@ registerToolWithTelemetry("inspect_at_point", {
1343
1936
  });
1344
1937
  // Tool: Get network requests
1345
1938
  registerToolWithTelemetry("get_network_requests", {
1346
- description: "Retrieve captured network requests from connected React Native app. Shows URL, method, status, and timing. Tip: Use summary=true first for stats overview (counts by method, status, domain), then fetch specific requests as needed.",
1939
+ description: "Retrieve captured network requests from connected React Native app. Shows URL, method, status, and timing. Note: On Bridgeless targets (Expo SDK 52+) without the SDK, capture may miss early startup requests. Install react-native-ai-devtools-sdk for full capture with headers and response bodies. Tip: Use summary=true first for stats overview.",
1347
1940
  inputSchema: {
1348
1941
  maxRequests: z
1349
1942
  .number()
@@ -1362,16 +1955,67 @@ registerToolWithTelemetry("get_network_requests", {
1362
1955
  .boolean()
1363
1956
  .optional()
1364
1957
  .default(false)
1365
- .describe("Return statistics only (count, methods, domains, status codes). Use for quick overview.")
1958
+ .describe("Return statistics only (count, methods, domains, status codes). Use for quick overview."),
1959
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
1960
+ }
1961
+ }, async ({ maxRequests, method, urlPattern, status, format, summary, device }) => {
1962
+ // Check if SDK is installed — prefer SDK data over CDP/interceptor buffer
1963
+ const sdkAvailable = await isSDKInstalled();
1964
+ if (sdkAvailable) {
1965
+ if (summary) {
1966
+ const sdkStats = await getSDKNetworkStats();
1967
+ if (sdkStats.success) {
1968
+ const s = sdkStats.data;
1969
+ const lines = [];
1970
+ lines.push(`Total requests: ${s.total}`);
1971
+ lines.push(`Completed: ${s.completed}`);
1972
+ lines.push(`Errors: ${s.errors}`);
1973
+ if (s.avgDuration != null)
1974
+ lines.push(`Avg duration: ${s.avgDuration}ms`);
1975
+ if (s.byMethod && Object.keys(s.byMethod).length > 0) {
1976
+ lines.push("\nBy Method:");
1977
+ for (const [m, c] of Object.entries(s.byMethod))
1978
+ lines.push(` ${m}: ${c}`);
1979
+ }
1980
+ if (s.byStatus && Object.keys(s.byStatus).length > 0) {
1981
+ lines.push("\nBy Status:");
1982
+ for (const [st, c] of Object.entries(s.byStatus))
1983
+ lines.push(` ${st}: ${c}`);
1984
+ }
1985
+ if (s.byDomain && Object.keys(s.byDomain).length > 0) {
1986
+ lines.push("\nBy Domain:");
1987
+ for (const [d, c] of Object.entries(s.byDomain).sort((a, b) => b[1] - a[1]).slice(0, 10))
1988
+ lines.push(` ${d}: ${c}`);
1989
+ }
1990
+ return { content: [{ type: "text", text: `Network Summary (SDK):\n\n${lines.join("\n")}` }] };
1991
+ }
1992
+ }
1993
+ const sdkResult = await querySDKNetwork({ count: maxRequests, method, urlPattern, status });
1994
+ if (sdkResult.success) {
1995
+ const entries = sdkResult.data;
1996
+ if (entries.length === 0) {
1997
+ return { content: [{ type: "text", text: "No network requests captured yet." }] };
1998
+ }
1999
+ const lines = entries.map((r) => {
2000
+ const time = new Date(r.timestamp).toLocaleTimeString();
2001
+ const st = r.status ?? "pending";
2002
+ const dur = r.duration != null ? `${r.duration}ms` : "-";
2003
+ return `[${r.id}] ${time} ${r.method} ${st} ${dur} ${r.url}`;
2004
+ });
2005
+ return { content: [{ type: "text", text: `Network Requests (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
2006
+ }
1366
2007
  }
1367
- }, async ({ maxRequests, method, urlPattern, status, format, summary }) => {
2008
+ // Fallback: read from in-process buffer (CDP/interceptor)
1368
2009
  // Return summary if requested
1369
2010
  if (summary) {
1370
- const stats = getNetworkStats(networkBuffer);
2011
+ const stats = getNetworkStats(resolveNetworkBuffer(device));
1371
2012
  let connectionWarning = "";
1372
- if (networkBuffer.size === 0) {
1373
- const connStatus = await checkAndEnsureConnection();
2013
+ if (resolveNetworkBuffer(device).size === 0) {
2014
+ const connStatus = await checkAndEnsureConnection(device);
1374
2015
  connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
2016
+ if (!sdkAvailable) {
2017
+ connectionWarning += "\n\n[TIP] For full network capture including startup requests and response bodies, install the SDK: npm install react-native-ai-devtools-sdk";
2018
+ }
1375
2019
  }
1376
2020
  return {
1377
2021
  content: [
@@ -1382,7 +2026,7 @@ registerToolWithTelemetry("get_network_requests", {
1382
2026
  ]
1383
2027
  };
1384
2028
  }
1385
- const { requests, count, formatted } = getNetworkRequests(networkBuffer, {
2029
+ const { requests, count, formatted } = getNetworkRequests(resolveNetworkBuffer(device), {
1386
2030
  maxRequests,
1387
2031
  method,
1388
2032
  urlPattern,
@@ -1391,8 +2035,11 @@ registerToolWithTelemetry("get_network_requests", {
1391
2035
  // Check connection health
1392
2036
  let connectionWarning = "";
1393
2037
  if (count === 0) {
1394
- const connStatus = await checkAndEnsureConnection();
2038
+ const connStatus = await checkAndEnsureConnection(device);
1395
2039
  connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
2040
+ if (!sdkAvailable) {
2041
+ connectionWarning += "\n\n[TIP] For full network capture including startup requests and response bodies, install the SDK: npm install react-native-ai-devtools-sdk";
2042
+ }
1396
2043
  }
1397
2044
  else {
1398
2045
  const passive = getPassiveConnectionStatus();
@@ -1435,7 +2082,10 @@ registerToolWithTelemetry("get_network_requests", {
1435
2082
  }
1436
2083
  ]
1437
2084
  };
1438
- });
2085
+ },
2086
+ // Empty result detector: buffer has no entries at all
2087
+ () => { let total = 0; for (const b of networkBuffers.values())
2088
+ total += b.size; return total === 0; });
1439
2089
  // Tool: Search network requests
1440
2090
  registerToolWithTelemetry("search_network", {
1441
2091
  description: "Search network requests by URL pattern (case-insensitive)",
@@ -1446,14 +2096,36 @@ registerToolWithTelemetry("search_network", {
1446
2096
  .enum(["text", "tonl"])
1447
2097
  .optional()
1448
2098
  .default("tonl")
1449
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)")
2099
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)"),
2100
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
2101
+ }
2102
+ }, async ({ urlPattern, maxResults, format, device }) => {
2103
+ // Check if SDK is installed — prefer SDK data
2104
+ const sdkAvailable = await isSDKInstalled();
2105
+ if (sdkAvailable) {
2106
+ const sdkResult = await querySDKNetwork({ count: maxResults, urlPattern });
2107
+ if (sdkResult.success) {
2108
+ const entries = sdkResult.data;
2109
+ if (entries.length === 0) {
2110
+ return { content: [{ type: "text", text: `No network requests matching "${urlPattern}" found.` }] };
2111
+ }
2112
+ const lines = entries.map((r) => {
2113
+ const time = new Date(r.timestamp).toLocaleTimeString();
2114
+ const st = r.status ?? "pending";
2115
+ const dur = r.duration != null ? `${r.duration}ms` : "-";
2116
+ return `[${r.id}] ${time} ${r.method} ${st} ${dur} ${r.url}`;
2117
+ });
2118
+ if (format === "tonl") {
2119
+ return { content: [{ type: "text", text: `Network search results for "${urlPattern}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
2120
+ }
2121
+ return { content: [{ type: "text", text: `Network search results for "${urlPattern}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
2122
+ }
1450
2123
  }
1451
- }, async ({ urlPattern, maxResults, format }) => {
1452
- const { requests, count, formatted } = searchNetworkRequests(networkBuffer, urlPattern, maxResults);
2124
+ const { requests, count, formatted } = searchNetworkRequests(resolveNetworkBuffer(device), urlPattern, maxResults);
1453
2125
  // Check connection health
1454
2126
  let connectionWarning = "";
1455
2127
  if (count === 0) {
1456
- const status = await checkAndEnsureConnection();
2128
+ const status = await checkAndEnsureConnection(device);
1457
2129
  connectionWarning = status.message ? `\n\n${status.message}` : "";
1458
2130
  }
1459
2131
  else {
@@ -1485,7 +2157,7 @@ registerToolWithTelemetry("search_network", {
1485
2157
  });
1486
2158
  // Tool: Get request details
1487
2159
  registerToolWithTelemetry("get_request_details", {
1488
- description: "Get full details of a specific network request including headers, body, and timing. Use get_network_requests first to find the request ID.",
2160
+ description: "Get full details of a specific network request including headers, body, and timing. With the SDK installed, includes full request/response bodies. Without SDK, bodies are not available on most targets. Use get_network_requests first to find the request ID.",
1489
2161
  inputSchema: {
1490
2162
  requestId: z.string().describe("The request ID to get details for"),
1491
2163
  maxBodyLength: z.coerce
@@ -1497,12 +2169,60 @@ registerToolWithTelemetry("get_request_details", {
1497
2169
  .boolean()
1498
2170
  .optional()
1499
2171
  .default(false)
1500
- .describe("Disable body truncation. Tip: Use when you need to inspect full JSON payloads.")
2172
+ .describe("Disable body truncation. Tip: Use when you need to inspect full JSON payloads."),
2173
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
2174
+ }
2175
+ }, async ({ requestId, maxBodyLength, verbose, device }) => {
2176
+ // Check SDK first — it has full headers and body
2177
+ const sdkAvailable = await isSDKInstalled();
2178
+ if (sdkAvailable) {
2179
+ const sdkResult = await getSDKNetworkEntry(requestId);
2180
+ if (sdkResult.success && sdkResult.data) {
2181
+ const r = sdkResult.data;
2182
+ const lines = [];
2183
+ lines.push(`=== ${r.method} ${r.url} ===`);
2184
+ lines.push(`Request ID: ${r.id}`);
2185
+ lines.push(`Time: ${new Date(r.timestamp).toISOString()}`);
2186
+ lines.push(`Status: ${r.status ?? "pending"} ${r.statusText ?? ""}`);
2187
+ if (r.duration != null)
2188
+ lines.push(`Duration: ${r.duration}ms`);
2189
+ if (r.mimeType)
2190
+ lines.push(`Content-Type: ${r.mimeType}`);
2191
+ if (r.error)
2192
+ lines.push(`Error: ${r.error}`);
2193
+ if (r.requestHeaders && Object.keys(r.requestHeaders).length > 0) {
2194
+ lines.push("\n--- Request Headers ---");
2195
+ for (const [k, v] of Object.entries(r.requestHeaders))
2196
+ lines.push(`${k}: ${v}`);
2197
+ }
2198
+ if (r.requestBody) {
2199
+ lines.push("\n--- Request Body ---");
2200
+ let body = r.requestBody;
2201
+ if (!verbose && maxBodyLength > 0 && body.length > maxBodyLength) {
2202
+ body = body.slice(0, maxBodyLength) + `... [truncated: ${r.requestBody.length} chars]`;
2203
+ }
2204
+ lines.push(body);
2205
+ }
2206
+ if (r.responseHeaders && Object.keys(r.responseHeaders).length > 0) {
2207
+ lines.push("\n--- Response Headers ---");
2208
+ for (const [k, v] of Object.entries(r.responseHeaders))
2209
+ lines.push(`${k}: ${v}`);
2210
+ }
2211
+ if (r.responseBody) {
2212
+ lines.push("\n--- Response Body ---");
2213
+ let body = r.responseBody;
2214
+ if (!verbose && maxBodyLength > 0 && body.length > maxBodyLength) {
2215
+ body = body.slice(0, maxBodyLength) + `... [truncated: ${r.responseBody.length} chars]`;
2216
+ }
2217
+ lines.push(body);
2218
+ }
2219
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2220
+ }
1501
2221
  }
1502
- }, async ({ requestId, maxBodyLength, verbose }) => {
1503
- const request = networkBuffer.get(requestId);
2222
+ // Fallback: read from in-process buffer
2223
+ const request = resolveNetworkBuffer(device).get(requestId);
1504
2224
  if (!request) {
1505
- const status = await checkAndEnsureConnection();
2225
+ const status = await checkAndEnsureConnection(device);
1506
2226
  const connectionNote = status.message ? `\n\n${status.message}` : "";
1507
2227
  return {
1508
2228
  content: [
@@ -1526,13 +2246,45 @@ registerToolWithTelemetry("get_request_details", {
1526
2246
  // Tool: Get network stats
1527
2247
  registerToolWithTelemetry("get_network_stats", {
1528
2248
  description: "Get statistics about captured network requests: counts by method, status code, and domain.",
1529
- inputSchema: {}
1530
- }, async () => {
1531
- const stats = getNetworkStats(networkBuffer);
2249
+ inputSchema: {
2250
+ device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
2251
+ }
2252
+ }, async ({ device }) => {
2253
+ // Check if SDK is installed — prefer SDK data
2254
+ const sdkAvailable = await isSDKInstalled();
2255
+ if (sdkAvailable) {
2256
+ const sdkStats = await getSDKNetworkStats();
2257
+ if (sdkStats.success) {
2258
+ const s = sdkStats.data;
2259
+ const lines = [];
2260
+ lines.push(`Total requests: ${s.total}`);
2261
+ lines.push(`Completed: ${s.completed}`);
2262
+ lines.push(`Errors: ${s.errors}`);
2263
+ if (s.avgDuration != null)
2264
+ lines.push(`Avg duration: ${s.avgDuration}ms`);
2265
+ if (s.byMethod && Object.keys(s.byMethod).length > 0) {
2266
+ lines.push("\nBy Method:");
2267
+ for (const [m, c] of Object.entries(s.byMethod))
2268
+ lines.push(` ${m}: ${c}`);
2269
+ }
2270
+ if (s.byStatus && Object.keys(s.byStatus).length > 0) {
2271
+ lines.push("\nBy Status:");
2272
+ for (const [st, c] of Object.entries(s.byStatus))
2273
+ lines.push(` ${st}: ${c}`);
2274
+ }
2275
+ if (s.byDomain && Object.keys(s.byDomain).length > 0) {
2276
+ lines.push("\nBy Domain:");
2277
+ for (const [d, c] of Object.entries(s.byDomain).sort((a, b) => b[1] - a[1]).slice(0, 10))
2278
+ lines.push(` ${d}: ${c}`);
2279
+ }
2280
+ return { content: [{ type: "text", text: `Network Statistics (SDK):\n\n${lines.join("\n")}` }] };
2281
+ }
2282
+ }
2283
+ const stats = getNetworkStats(resolveNetworkBuffer(device));
1532
2284
  // Check connection health
1533
2285
  let connectionWarning = "";
1534
- if (networkBuffer.size === 0) {
1535
- const status = await checkAndEnsureConnection();
2286
+ if (resolveNetworkBuffer(device).size === 0) {
2287
+ const status = await checkAndEnsureConnection(device);
1536
2288
  connectionWarning = status.message ? `\n\n${status.message}` : "";
1537
2289
  }
1538
2290
  else {
@@ -1549,37 +2301,126 @@ registerToolWithTelemetry("get_network_stats", {
1549
2301
  }
1550
2302
  ]
1551
2303
  };
1552
- });
2304
+ },
2305
+ // Empty result detector: buffer has no entries at all
2306
+ () => { let total = 0; for (const b of networkBuffers.values())
2307
+ total += b.size; return total === 0; });
1553
2308
  // Tool: Clear network requests
1554
2309
  registerToolWithTelemetry("clear_network", {
1555
2310
  description: "Clear the network request buffer",
1556
- inputSchema: {}
1557
- }, async () => {
1558
- const count = networkBuffer.clear();
2311
+ inputSchema: {
2312
+ device: z.string().optional().describe("Target device name (substring match). Omit to clear all devices. Run get_apps to see connected devices.")
2313
+ }
2314
+ }, async ({ device }) => {
2315
+ let totalCleared = 0;
2316
+ if (device) {
2317
+ const app = getConnectedAppByDevice(device);
2318
+ if (!app)
2319
+ throw new Error(`No connected device matches "${device}"`);
2320
+ const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
2321
+ totalCleared = getNetworkBuffer(deviceName).clear();
2322
+ }
2323
+ else {
2324
+ for (const buffer of networkBuffers.values()) {
2325
+ totalCleared += buffer.clear();
2326
+ }
2327
+ }
2328
+ // Also clear SDK buffer if available
2329
+ const sdkAvailable = await isSDKInstalled();
2330
+ if (sdkAvailable) {
2331
+ const sdkResult = await clearSDKNetwork();
2332
+ if (sdkResult.success && sdkResult.count) {
2333
+ totalCleared += sdkResult.count;
2334
+ }
2335
+ }
1559
2336
  return {
1560
2337
  content: [
1561
2338
  {
1562
2339
  type: "text",
1563
- text: `Cleared ${count} network requests from buffer.`
2340
+ text: `Cleared ${totalCleared} network requests from buffer.`
1564
2341
  }
1565
2342
  ]
1566
2343
  };
1567
2344
  });
1568
- // Tool: Reload the app
1569
- registerToolWithTelemetry("reload_app", {
1570
- description: "Reload the React Native app (triggers JavaScript bundle reload like pressing 'r' in Metro). Will auto-connect to Metro if no connection exists. Note: After reload, the app may take a few seconds to fully restart and become responsive — wait before running other tools. IMPORTANT: React Native has Fast Refresh enabled by default - code changes are automatically applied without needing reload. Only use when: (1) logs/behavior don't reflect code changes after a few seconds, (2) app is in broken/error state, or (3) need to reset app state completely (navigation stack, context, etc.).",
1571
- inputSchema: {}
1572
- }, async () => {
1573
- const result = await reloadApp();
1574
- if (!result.success) {
2345
+ // Tool: Get images from shared image buffer
2346
+ registerToolWithTelemetry("get_images", {
2347
+ description: "Access the shared image buffer containing screenshots from all tools (ios_screenshot, android_screenshot, ocr_screenshot, tap verification). Returns metadata only by default — use id or groupId+frameIndex to retrieve actual image data. Tap burst verification stores frame groups here when burst=true is used.",
2348
+ inputSchema: {
2349
+ list: z.boolean().optional().describe("List all entries and groups (metadata only, no image data)"),
2350
+ id: z.string().optional().describe("Retrieve a specific image by ID (returns image data)"),
2351
+ groupId: z.string().optional().describe("List frames in a group (metadata only), or combine with frameIndex to retrieve a specific frame"),
2352
+ frameIndex: z.coerce.number().optional().describe("Retrieve a specific frame from a group (requires groupId)"),
2353
+ last: z.coerce.number().optional().describe("Return the N most recent entries (metadata only)"),
2354
+ source: z.string().optional().describe("Filter entries by source"),
2355
+ clear: z.boolean().optional().describe("Clear the buffer")
2356
+ }
2357
+ }, async ({ id, groupId, frameIndex, last, source, clear }) => {
2358
+ if (clear) {
2359
+ const count = imageBuffer.clear();
1575
2360
  return {
1576
- content: [
1577
- {
1578
- type: "text",
1579
- text: `Error: ${result.error}`
1580
- }
1581
- ],
1582
- isError: true
2361
+ content: [{ type: "text", text: `Cleared ${count} images from buffer.` }]
2362
+ };
2363
+ }
2364
+ if (id) {
2365
+ const entry = imageBuffer.getById(id);
2366
+ if (!entry) {
2367
+ return {
2368
+ content: [{ type: "text", text: `No image found with id "${id}".` }],
2369
+ isError: true
2370
+ };
2371
+ }
2372
+ const { image, ...meta } = entry;
2373
+ return {
2374
+ content: [
2375
+ { type: "text", text: JSON.stringify(meta, null, 2) },
2376
+ { type: "image", data: image.toString("base64"), mimeType: "image/png" }
2377
+ ]
2378
+ };
2379
+ }
2380
+ if (groupId !== undefined && frameIndex !== undefined) {
2381
+ const entry = imageBuffer.getByGroupFrame(groupId, frameIndex);
2382
+ if (!entry) {
2383
+ return {
2384
+ content: [{ type: "text", text: `No frame ${frameIndex} found in group "${groupId}".` }],
2385
+ isError: true
2386
+ };
2387
+ }
2388
+ const { image, ...meta } = entry;
2389
+ return {
2390
+ content: [
2391
+ { type: "text", text: JSON.stringify(meta, null, 2) },
2392
+ { type: "image", data: image.toString("base64"), mimeType: "image/png" }
2393
+ ]
2394
+ };
2395
+ }
2396
+ const entries = imageBuffer.listEntries({ source, groupId, last });
2397
+ const groups = imageBuffer.listGroups();
2398
+ return {
2399
+ content: [
2400
+ {
2401
+ type: "text",
2402
+ text: JSON.stringify({ entries, groups, total: imageBuffer.size }, null, 2)
2403
+ }
2404
+ ]
2405
+ };
2406
+ });
2407
+ // Tool: Reload the app
2408
+ registerToolWithTelemetry("reload_app", {
2409
+ description: "Reload the React Native app (triggers JavaScript bundle reload like pressing 'r' in Metro). Will auto-connect to Metro if no connection exists. Note: After reload, the app may take a few seconds to fully restart and become responsive — wait before running other tools. IMPORTANT: React Native has Fast Refresh enabled by default - code changes are automatically applied without needing reload. Only use when: (1) logs/behavior don't reflect code changes after a few seconds, (2) app is in broken/error state, or (3) need to reset app state completely (navigation stack, context, etc.).",
2410
+ inputSchema: {
2411
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2412
+ }
2413
+ }, async ({ device }) => {
2414
+ const result = await reloadApp(device);
2415
+ if (!result.success) {
2416
+ return {
2417
+ content: [
2418
+ {
2419
+ type: "text",
2420
+ text: `Error: ${result.error}`
2421
+ }
2422
+ ],
2423
+ isError: true
1583
2424
  };
1584
2425
  }
1585
2426
  return {
@@ -1591,6 +2432,170 @@ registerToolWithTelemetry("reload_app", {
1591
2432
  ]
1592
2433
  };
1593
2434
  });
2435
+ // Tool: LogBox control (replaces dismiss_logbox)
2436
+ registerToolWithTelemetry("logbox", {
2437
+ description: "Interact with React Native's LogBox overlay (dev mode only). " +
2438
+ 'Actions: "dismiss" clears all entries and returns their content. ' +
2439
+ '"push" displays a message in the LogBox error banner (visible to the developer watching the device). ' +
2440
+ '"ignore" adds patterns to suppress future LogBox entries for this session. ' +
2441
+ '"detect" reads current LogBox state without modifying it. ' +
2442
+ "Only works in __DEV__ mode — LogBox does not exist in production builds.",
2443
+ inputSchema: {
2444
+ action: z.enum(["dismiss", "push", "ignore", "detect"]).describe('Action to perform: "dismiss", "push", "ignore", or "detect"'),
2445
+ message: z.string().optional().describe('Message to push into LogBox (required when action="push")'),
2446
+ level: z.enum(["error", "warning"]).optional().describe('LogBox level for push (default: "error"). Only "error" shows a visible bottom banner; "warning" is stored but not visually shown unless LogBox is already open'),
2447
+ expanded: z.boolean().optional().describe('When true, opens the full-screen LogBox detail view instead of the minimized bottom banner. Useful for important messages with clickable URLs (default: false)'),
2448
+ subtitle: z.string().optional().describe('Additional info shown in the call stack area when expanded=true (default: "MCP Server"). Use for context like "License Check", "Usage Limit", etc.'),
2449
+ target: z.enum(["logbox", "metro"]).optional().describe('Where to push the message (default: "logbox"). "logbox" shows on device screen, "metro" outputs to Metro terminal via console.log'),
2450
+ patterns: z.array(z.string()).optional().describe('Patterns to ignore (required when action="ignore"), e.g. ["[APOLLO]", "deprecated"]'),
2451
+ device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2452
+ }
2453
+ }, async ({ action, message, level, expanded, subtitle, target, patterns, device }) => {
2454
+ if (action === "dismiss") {
2455
+ const result = await dismissLogBox(device);
2456
+ if (result === null) {
2457
+ return {
2458
+ content: [
2459
+ {
2460
+ type: "text",
2461
+ text: "LogBox not available. This can happen if: (1) the app is a production build (__DEV__ is false), (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2462
+ }
2463
+ ]
2464
+ };
2465
+ }
2466
+ if (result.totalDismissed === 0) {
2467
+ return {
2468
+ content: [
2469
+ {
2470
+ type: "text",
2471
+ text: "No LogBox entries to dismiss."
2472
+ }
2473
+ ]
2474
+ };
2475
+ }
2476
+ return {
2477
+ content: [
2478
+ {
2479
+ type: "text",
2480
+ text: formatDismissedEntries(result)
2481
+ }
2482
+ ]
2483
+ };
2484
+ }
2485
+ if (action === "push") {
2486
+ if (!message) {
2487
+ return {
2488
+ content: [
2489
+ {
2490
+ type: "text",
2491
+ text: 'Error: "message" parameter is required when action is "push".'
2492
+ }
2493
+ ],
2494
+ isError: true
2495
+ };
2496
+ }
2497
+ const success = await pushLogBox(message, level || "error", expanded || false, target || "logbox", subtitle, device);
2498
+ if (!success) {
2499
+ return {
2500
+ content: [
2501
+ {
2502
+ type: "text",
2503
+ text: "Failed to push message to LogBox. This can happen if: (1) the app is a production build, (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2504
+ }
2505
+ ]
2506
+ };
2507
+ }
2508
+ return {
2509
+ content: [
2510
+ {
2511
+ type: "text",
2512
+ text: target === "metro"
2513
+ ? `Message pushed to Metro terminal: "${message}"`
2514
+ : `Message pushed to LogBox as ${level || "error"}: "${message}"`
2515
+ }
2516
+ ]
2517
+ };
2518
+ }
2519
+ if (action === "ignore") {
2520
+ if (!patterns || patterns.length === 0) {
2521
+ return {
2522
+ content: [
2523
+ {
2524
+ type: "text",
2525
+ text: 'Error: "patterns" parameter is required when action is "ignore" (non-empty array of strings).'
2526
+ }
2527
+ ],
2528
+ isError: true
2529
+ };
2530
+ }
2531
+ const activePatterns = await addLogBoxIgnorePatterns(patterns, device);
2532
+ if (activePatterns === null) {
2533
+ return {
2534
+ content: [
2535
+ {
2536
+ type: "text",
2537
+ text: "Failed to add ignore patterns. This can happen if: (1) the app is a production build, (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2538
+ }
2539
+ ]
2540
+ };
2541
+ }
2542
+ return {
2543
+ content: [
2544
+ {
2545
+ type: "text",
2546
+ text: `Added ignore patterns: ${patterns.map((p) => `"${p}"`).join(", ")}\n\nAll active ignore patterns: ${activePatterns.map((p) => `"${p}"`).join(", ")}`
2547
+ }
2548
+ ]
2549
+ };
2550
+ }
2551
+ if (action === "detect") {
2552
+ const state = await detectLogBox(device);
2553
+ if (state === null) {
2554
+ return {
2555
+ content: [
2556
+ {
2557
+ type: "text",
2558
+ text: "LogBox not available. This can happen if: (1) the app is a production build (__DEV__ is false), (2) no React Native app is connected, or (3) the Metro module registry is not accessible."
2559
+ }
2560
+ ]
2561
+ };
2562
+ }
2563
+ if (state.total === 0) {
2564
+ return {
2565
+ content: [
2566
+ {
2567
+ type: "text",
2568
+ text: "No LogBox entries detected."
2569
+ }
2570
+ ]
2571
+ };
2572
+ }
2573
+ const MAX_MSG_LENGTH = 150;
2574
+ let output = `LogBox state: ${state.total} entr${state.total === 1 ? "y" : "ies"} (${state.errors} error${state.errors !== 1 ? "s" : ""}, ${state.warnings} warning${state.warnings !== 1 ? "s" : ""}, ${state.fatals} fatal${state.fatals !== 1 ? "s" : ""})\n`;
2575
+ for (const entry of state.entries) {
2576
+ const truncated = entry.message.length > MAX_MSG_LENGTH ? entry.message.substring(0, MAX_MSG_LENGTH) + "..." : entry.message;
2577
+ const countStr = entry.count > 1 ? ` (x${entry.count})` : "";
2578
+ output += `\n[${entry.level}] ${truncated}${countStr}`;
2579
+ }
2580
+ return {
2581
+ content: [
2582
+ {
2583
+ type: "text",
2584
+ text: output
2585
+ }
2586
+ ]
2587
+ };
2588
+ }
2589
+ return {
2590
+ content: [
2591
+ {
2592
+ type: "text",
2593
+ text: `Unknown action: "${action}". Use "dismiss", "push", "ignore", or "detect".`
2594
+ }
2595
+ ],
2596
+ isError: true
2597
+ };
2598
+ });
1594
2599
  // ============================================================================
1595
2600
  // Bundle/Build Error Tools
1596
2601
  // ============================================================================
@@ -1776,7 +2781,7 @@ registerToolWithTelemetry("list_android_devices", {
1776
2781
  });
1777
2782
  // Tool: Android screenshot
1778
2783
  registerToolWithTelemetry("android_screenshot", {
1779
- description: "Take a screenshot from an Android device/emulator. Returns the image data that can be displayed.",
2784
+ description: "Take a screenshot from an Android device/emulator. Returns the image plus two lists: (1) pressable elements — the tappable components on screen with ready-to-use pixel tap coordinates, testIDs, and labels; (2) screen layout — all visible React components for context. Prefer tap(text=\"...\") when text is exact and unique; otherwise use tap(x, y) with coordinates from the pressables list — this is the most reliable way to tap icons or visually-identified elements. Use component names from the layout for inspect_component/find_components.",
1780
2785
  inputSchema: {
1781
2786
  outputPath: z
1782
2787
  .string()
@@ -1833,16 +2838,90 @@ registerToolWithTelemetry("android_screenshot", {
1833
2838
  catch {
1834
2839
  // Use defaults
1835
2840
  }
1836
- infoText += `\n📱 Android uses PIXELS for tap coordinates (same as screenshot)`;
2841
+ // Enrich with screen layout data (component names + tap coordinates)
2842
+ // Android: density ratio = densityDpi / 160 (160dpi = 1dp = 1px baseline)
2843
+ const androidPixelRatio = densityDpi / 160;
2844
+ let layoutText = null;
2845
+ try {
2846
+ layoutText = await enrichScreenshotWithLayout(androidPixelRatio, result.scaleFactor || 1);
2847
+ }
2848
+ catch {
2849
+ // Non-fatal: screenshot works without layout enrichment
2850
+ }
2851
+ // Enrich with pressable elements (filtered list of tappable components, converted to screenshot pixel coords)
2852
+ let pressablesText = null;
2853
+ try {
2854
+ const pressables = await getPressableElements({});
2855
+ if (pressables.success && pressables.parsedElements && pressables.parsedElements.length > 0) {
2856
+ const screenshotScale = result.scaleFactor || 1;
2857
+ pressablesText = pressables.parsedElements.map((el) => {
2858
+ const px = Math.round((el.center.x * androidPixelRatio) / screenshotScale);
2859
+ const py = Math.round((el.center.y * androidPixelRatio) / screenshotScale);
2860
+ const label = el.accessibilityLabel || el.text || el.testID || el.component;
2861
+ const idPart = el.testID ? ` testID="${el.testID}"` : "";
2862
+ const kindPart = el.isInput ? " [input]" : "";
2863
+ const wrapPart = el.isWrapper ? " [wrapper — skip]" : "";
2864
+ return ` (${px}, ${py}) ${el.component}: "${label}"${idPart}${kindPart}${wrapPart}`;
2865
+ }).join("\n");
2866
+ }
2867
+ }
2868
+ catch {
2869
+ // Non-fatal: screenshot works without pressables enrichment
2870
+ }
2871
+ infoText += `\n📱 Android uses PIXELS for all coordinates`;
1837
2872
  if (result.scaleFactor && result.scaleFactor > 1) {
1838
- infoText += `\n⚠️ Image was scaled down to fit API limits. Scale factor: ${result.scaleFactor.toFixed(3)}`;
1839
- infoText += `\n📐 To convert image coords to tap coords: multiply by ${result.scaleFactor.toFixed(3)}`;
2873
+ infoText += `\n🖼️ Image was scaled down to fit API limits (scale: ${result.scaleFactor.toFixed(3)})`;
2874
+ infoText += `\n📐 tap() handles coordinate conversion automatically — pass pixel coords from this image directly`;
1840
2875
  }
1841
2876
  else {
1842
2877
  infoText += `\n📐 Screenshot coords = tap coords (no conversion needed)`;
1843
2878
  }
1844
2879
  infoText += `\n⚠️ Status bar: ${statusBarPixels}px (${statusBarDp}dp) from top - app content starts below this`;
1845
2880
  infoText += `\n📊 Display density: ${densityDpi}dpi`;
2881
+ if (pressablesText) {
2882
+ infoText += `\n\n🎯 Pressable elements (ready-to-tap, coordinates in screenshot pixels):`;
2883
+ infoText += `\n${pressablesText}`;
2884
+ }
2885
+ if (layoutText) {
2886
+ infoText += `\n\n📋 Screen Layout (all components, tap coordinates in pixels):`;
2887
+ infoText += `\n${layoutText}`;
2888
+ }
2889
+ if (pressablesText || layoutText) {
2890
+ infoText += `\n\n💡 Next steps:`;
2891
+ infoText += `\n • tap(text="Button Label") — when text is exact and unique`;
2892
+ infoText += `\n • tap(testID="id") or tap(component="Name") — when you know the identifier`;
2893
+ infoText += `\n • tap(x=<px>, y=<px>) — use coordinates from the pressable elements list above (reliable for icons and ambiguous elements)`;
2894
+ infoText += `\n • inspect_component("ComponentName") — inspect a component from the layout`;
2895
+ }
2896
+ else {
2897
+ infoText += `\n\n💡 Next steps:`;
2898
+ infoText += `\n • tap(text="Button Label") — tap element by visible text`;
2899
+ infoText += `\n • tap(x=<px>, y=<px>) — tap at coordinates from this screenshot`;
2900
+ infoText += `\n • android_describe_all — get full UI tree with exact tap coordinates`;
2901
+ infoText += `\n • android_find_element(text="...") — find element coordinates without tapping`;
2902
+ }
2903
+ // Check for LogBox overlay (uses default CDP device — native deviceId cannot be mapped to CDP device name)
2904
+ try {
2905
+ const logBoxState = await detectLogBox();
2906
+ if (logBoxState && logBoxState.total > 0) {
2907
+ infoText += formatLogBoxWarning(logBoxState);
2908
+ }
2909
+ }
2910
+ catch {
2911
+ // Non-fatal: LogBox detection failure should not break screenshot
2912
+ }
2913
+ imageBuffer.add({
2914
+ id: `android-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2915
+ image: result.data,
2916
+ timestamp: Date.now(),
2917
+ source: "android_screenshot",
2918
+ metadata: {
2919
+ width: result.originalWidth || 0,
2920
+ height: result.originalHeight || 0,
2921
+ scaleFactor: result.scaleFactor || 1,
2922
+ platform: "android",
2923
+ },
2924
+ });
1846
2925
  return {
1847
2926
  content: [
1848
2927
  {
@@ -2090,11 +3169,24 @@ server.registerTool("android_describe_all", {
2090
3169
  }
2091
3170
  }, async ({ deviceId }) => {
2092
3171
  const result = await androidDescribeAll(deviceId);
3172
+ let text = result.success ? result.formatted : `Error: ${result.error}`;
3173
+ // Check for LogBox overlay
3174
+ if (result.success) {
3175
+ try {
3176
+ const logBoxState = await detectLogBox();
3177
+ if (logBoxState && logBoxState.total > 0) {
3178
+ text += formatLogBoxWarning(logBoxState);
3179
+ }
3180
+ }
3181
+ catch {
3182
+ // Non-fatal: LogBox detection failure should not break describe_all
3183
+ }
3184
+ }
2093
3185
  return {
2094
3186
  content: [
2095
3187
  {
2096
3188
  type: "text",
2097
- text: result.success ? result.formatted : `Error: ${result.error}`
3189
+ text
2098
3190
  }
2099
3191
  ],
2100
3192
  isError: !result.success
@@ -2277,7 +3369,7 @@ registerToolWithTelemetry("list_ios_simulators", {
2277
3369
  });
2278
3370
  // Tool: iOS screenshot
2279
3371
  registerToolWithTelemetry("ios_screenshot", {
2280
- description: "Take a screenshot from an iOS simulator. Returns the image data that can be displayed.",
3372
+ description: "Take a screenshot from an iOS simulator. Returns the image plus two lists: (1) pressable elements — the tappable components on screen with ready-to-use pixel tap coordinates, testIDs, and labels; (2) screen layout — all visible React components for context. Prefer tap(text=\"...\") when text is exact and unique; otherwise use tap(x, y) with coordinates from the pressables list — this is the most reliable way to tap icons or visually-identified elements. Use component names from the layout for inspect_component/find_components.",
2281
3373
  inputSchema: {
2282
3374
  outputPath: z
2283
3375
  .string()
@@ -2361,16 +3453,85 @@ registerToolWithTelemetry("ios_screenshot", {
2361
3453
  pointHeight = Math.round(pixelHeight / scaleFactor);
2362
3454
  }
2363
3455
  const safeAreaOffsetPixels = safeAreaTop * scaleFactor;
3456
+ // Enrich with screen layout data (component names + tap coordinates)
3457
+ let layoutText = null;
3458
+ try {
3459
+ layoutText = await enrichScreenshotWithLayout(scaleFactor, result.scaleFactor || 1);
3460
+ }
3461
+ catch {
3462
+ // Non-fatal: screenshot works without layout enrichment
3463
+ }
3464
+ // Enrich with pressable elements (filtered list of tappable components, converted to screenshot pixel coords)
3465
+ let pressablesText = null;
3466
+ try {
3467
+ const pressables = await getPressableElements({});
3468
+ if (pressables.success && pressables.parsedElements && pressables.parsedElements.length > 0) {
3469
+ const screenshotScale = result.scaleFactor || 1;
3470
+ pressablesText = pressables.parsedElements.map((el) => {
3471
+ const px = Math.round((el.center.x * scaleFactor) / screenshotScale);
3472
+ const py = Math.round((el.center.y * scaleFactor) / screenshotScale);
3473
+ const label = el.accessibilityLabel || el.text || el.testID || el.component;
3474
+ const idPart = el.testID ? ` testID="${el.testID}"` : "";
3475
+ const kindPart = el.isInput ? " [input]" : "";
3476
+ const wrapPart = el.isWrapper ? " [wrapper — skip]" : "";
3477
+ return ` (${px}, ${py}) ${el.component}: "${label}"${idPart}${kindPart}${wrapPart}`;
3478
+ }).join("\n");
3479
+ }
3480
+ }
3481
+ catch {
3482
+ // Non-fatal: screenshot works without pressables enrichment
3483
+ }
2364
3484
  let infoText = `Screenshot captured (${pixelWidth}x${pixelHeight} pixels)`;
2365
- infoText += `\n📱 iOS tap coordinates use POINTS: ${pointWidth}x${pointHeight}`;
2366
- infoText += `\n📐 To convert screenshot coords to tap points:`;
2367
- infoText += `\n tap_x = pixel_x / ${scaleFactor}`;
2368
- infoText += `\n tap_y = pixel_y / ${scaleFactor}`;
3485
+ infoText += `\n📱 iOS screen: ${pointWidth}x${pointHeight} points (${scaleFactor}x scale)`;
3486
+ infoText += `\n📐 tap() handles pixel-to-point conversion automatically — pass pixel coords from this image directly`;
2369
3487
  infoText += `\n⚠️ Status bar + safe area: ${safeAreaTop} points (${safeAreaOffsetPixels} pixels) from top`;
2370
3488
  if (result.scaleFactor && result.scaleFactor > 1) {
2371
3489
  infoText += `\n🖼️ Image was scaled down to fit API limits (scale: ${result.scaleFactor.toFixed(3)})`;
2372
3490
  }
2373
- infoText += `\n💡 Use ios_describe_all to get exact element coordinates`;
3491
+ if (pressablesText) {
3492
+ infoText += `\n\n🎯 Pressable elements (ready-to-tap, coordinates in screenshot pixels):`;
3493
+ infoText += `\n${pressablesText}`;
3494
+ }
3495
+ if (layoutText) {
3496
+ infoText += `\n\n📋 Screen Layout (all components, tap coordinates in pixels):`;
3497
+ infoText += `\n${layoutText}`;
3498
+ }
3499
+ if (pressablesText || layoutText) {
3500
+ infoText += `\n\n💡 Next steps:`;
3501
+ infoText += `\n • tap(text="Button Label") — when text is exact and unique`;
3502
+ infoText += `\n • tap(testID="id") or tap(component="Name") — when you know the identifier`;
3503
+ infoText += `\n • tap(x=<px>, y=<px>) — use coordinates from the pressable elements list above (reliable for icons and ambiguous elements)`;
3504
+ infoText += `\n • inspect_component("ComponentName") — inspect a component from the layout`;
3505
+ }
3506
+ else {
3507
+ infoText += `\n\n💡 Next steps:`;
3508
+ infoText += `\n • tap(text="Button Label") — tap element by visible text`;
3509
+ infoText += `\n • tap(x=<px>, y=<px>) — tap at coordinates from this screenshot`;
3510
+ infoText += `\n • ios_describe_all — get full UI tree with exact tap coordinates`;
3511
+ infoText += `\n • ios_find_element(label="...") — find element coordinates without tapping`;
3512
+ }
3513
+ // Check for LogBox overlay (uses default CDP device — native UDID cannot be mapped to CDP device name)
3514
+ try {
3515
+ const logBoxState = await detectLogBox();
3516
+ if (logBoxState && logBoxState.total > 0) {
3517
+ infoText += formatLogBoxWarning(logBoxState);
3518
+ }
3519
+ }
3520
+ catch {
3521
+ // Non-fatal: LogBox detection failure should not break screenshot
3522
+ }
3523
+ imageBuffer.add({
3524
+ id: `ios-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3525
+ image: result.data,
3526
+ timestamp: Date.now(),
3527
+ source: "ios_screenshot",
3528
+ metadata: {
3529
+ width: result.originalWidth || 0,
3530
+ height: result.originalHeight || 0,
3531
+ scaleFactor: result.scaleFactor || 1,
3532
+ platform: "ios",
3533
+ },
3534
+ });
2374
3535
  return {
2375
3536
  content: [
2376
3537
  {
@@ -2407,32 +3568,39 @@ registerToolWithTelemetry("ocr_screenshot", {
2407
3568
  .describe("Optional device ID (Android) or UDID (iOS). Uses first available device if not specified.")
2408
3569
  }
2409
3570
  }, async ({ platform, deviceId }) => {
2410
- // Call the HTTP endpoint for OCR (allows hot-reload without session restart)
2411
- // Prefer child process port, fall back to in-process port
2412
- const port = getDebugServerPort();
2413
- if (!port) {
2414
- return {
2415
- content: [
2416
- {
2417
- type: "text",
2418
- text: "Debug HTTP server not running"
2419
- }
2420
- ],
2421
- isError: true
2422
- };
2423
- }
2424
3571
  try {
2425
- const params = new URLSearchParams({ platform, engine: "auto" });
2426
- if (deviceId)
2427
- params.set("deviceId", deviceId);
2428
- const response = await fetch(`http://localhost:${port}/api/ocr?${params}`);
2429
- const ocrResult = await response.json();
3572
+ // Take screenshot
3573
+ const screenshotResult = platform === "android"
3574
+ ? await androidScreenshot(undefined, deviceId)
3575
+ : await iosScreenshot(undefined, deviceId);
3576
+ if (!screenshotResult.success || !screenshotResult.data) {
3577
+ return {
3578
+ content: [
3579
+ {
3580
+ type: "text",
3581
+ text: `Screenshot failed: ${screenshotResult.error || "No image data"}`
3582
+ }
3583
+ ],
3584
+ isError: true
3585
+ };
3586
+ }
3587
+ // Calculate device pixel ratio for iOS
3588
+ const devicePixelRatio = platform === "ios" && screenshotResult.originalWidth && screenshotResult.originalHeight
3589
+ ? inferIOSDevicePixelRatio(screenshotResult.originalWidth, screenshotResult.originalHeight)
3590
+ : 1;
3591
+ // Run OCR on the screenshot
3592
+ const scaleFactor = screenshotResult.scaleFactor || 1;
3593
+ const ocrResult = await recognizeText(screenshotResult.data, {
3594
+ scaleFactor,
3595
+ platform,
3596
+ devicePixelRatio
3597
+ });
2430
3598
  if (!ocrResult.success) {
2431
3599
  return {
2432
3600
  content: [
2433
3601
  {
2434
3602
  type: "text",
2435
- text: `OCR failed: ${ocrResult.error || "Unknown error"}`
3603
+ text: `OCR failed: no text recognized`
2436
3604
  }
2437
3605
  ],
2438
3606
  isError: true
@@ -2455,8 +3623,36 @@ registerToolWithTelemetry("ocr_screenshot", {
2455
3623
  confidence: Math.round(ocrResult.confidence || 0),
2456
3624
  elementCount: elements.length,
2457
3625
  elements,
2458
- note: "tapX/tapY are ready to use with tap commands (already converted for platform)"
3626
+ note: "tapX/tapY are in device pixels — pass directly to tap(x, y) for automatic platform conversion"
2459
3627
  };
3628
+ // Check for LogBox overlay (uses default CDP device — native deviceId cannot be mapped to CDP device name)
3629
+ try {
3630
+ const logBoxState = await detectLogBox();
3631
+ if (logBoxState && logBoxState.total > 0) {
3632
+ result.logBoxWarning = formatLogBoxWarning(logBoxState).trim();
3633
+ }
3634
+ }
3635
+ catch {
3636
+ // Non-fatal: LogBox detection failure should not break OCR
3637
+ }
3638
+ // Store screenshot in image buffer
3639
+ try {
3640
+ imageBuffer.add({
3641
+ id: `ocr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
3642
+ image: screenshotResult.data,
3643
+ timestamp: Date.now(),
3644
+ source: "ocr_screenshot",
3645
+ metadata: {
3646
+ width: screenshotResult.originalWidth || 0,
3647
+ height: screenshotResult.originalHeight || 0,
3648
+ scaleFactor,
3649
+ platform,
3650
+ },
3651
+ });
3652
+ }
3653
+ catch {
3654
+ // Non-fatal: image buffer write failure should not break OCR response
3655
+ }
2460
3656
  return {
2461
3657
  content: [
2462
3658
  {
@@ -2471,7 +3667,7 @@ registerToolWithTelemetry("ocr_screenshot", {
2471
3667
  content: [
2472
3668
  {
2473
3669
  type: "text",
2474
- text: `OCR request failed: ${error instanceof Error ? error.message : String(error)}`
3670
+ text: `OCR failed: ${error instanceof Error ? error.message : String(error)}`
2475
3671
  }
2476
3672
  ],
2477
3673
  isError: true
@@ -2573,12 +3769,13 @@ registerToolWithTelemetry("ios_boot_simulator", {
2573
3769
  };
2574
3770
  });
2575
3771
  // ============================================================================
2576
- // iOS IDB-Based UI Tools (require Facebook IDB)
2577
- // Install with: brew install idb-companion
3772
+ // iOS UI Interaction Tools (require an iOS UI driver)
3773
+ // Recommended: AXe — brew install cameroncooke/axe/axe (set IOS_DRIVER=axe)
3774
+ // Alternative: IDB — brew install idb-companion (default)
2578
3775
  // ============================================================================
2579
3776
  // Tool: iOS swipe
2580
3777
  server.registerTool("ios_swipe", {
2581
- description: "Swipe gesture on an iOS simulator screen. Requires IDB to be installed (brew install idb-companion).",
3778
+ description: "Swipe gesture on an iOS simulator screen. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion).",
2582
3779
  inputSchema: {
2583
3780
  startX: z.coerce.number().describe("Starting X coordinate in pixels"),
2584
3781
  startY: z.coerce.number().describe("Starting Y coordinate in pixels"),
@@ -2602,7 +3799,7 @@ server.registerTool("ios_swipe", {
2602
3799
  });
2603
3800
  // Tool: iOS input text
2604
3801
  server.registerTool("ios_input_text", {
2605
- description: "Type text into the active input field on an iOS simulator. Requires IDB to be installed (brew install idb-companion).",
3802
+ description: "Type text into the active input field on an iOS simulator. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion).",
2606
3803
  inputSchema: {
2607
3804
  text: z.string().describe("Text to type into the active input field"),
2608
3805
  udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
@@ -2621,7 +3818,7 @@ server.registerTool("ios_input_text", {
2621
3818
  });
2622
3819
  // Tool: iOS button
2623
3820
  server.registerTool("ios_button", {
2624
- description: "Press a hardware button on an iOS simulator. Requires IDB to be installed (brew install idb-companion).",
3821
+ description: "Press a hardware button on an iOS simulator. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion).",
2625
3822
  inputSchema: {
2626
3823
  button: z
2627
3824
  .enum(IOS_BUTTON_TYPES)
@@ -2643,7 +3840,7 @@ server.registerTool("ios_button", {
2643
3840
  });
2644
3841
  // Tool: iOS key event
2645
3842
  server.registerTool("ios_key_event", {
2646
- description: "Send a key event to an iOS simulator by keycode. Requires IDB to be installed (brew install idb-companion).",
3843
+ description: "Send a key event to an iOS simulator by keycode. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion).",
2647
3844
  inputSchema: {
2648
3845
  keycode: z.coerce.number().describe("iOS keycode to send"),
2649
3846
  duration: z.coerce.number().optional().describe("Optional key press duration in seconds"),
@@ -2663,7 +3860,7 @@ server.registerTool("ios_key_event", {
2663
3860
  });
2664
3861
  // Tool: iOS key sequence
2665
3862
  server.registerTool("ios_key_sequence", {
2666
- description: "Send a sequence of key events to an iOS simulator. Requires IDB to be installed (brew install idb-companion).",
3863
+ description: "Send a sequence of key events to an iOS simulator. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion).",
2667
3864
  inputSchema: {
2668
3865
  keycodes: z.array(z.coerce.number()).describe("Array of iOS keycodes to send in sequence"),
2669
3866
  udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
@@ -2682,17 +3879,30 @@ server.registerTool("ios_key_sequence", {
2682
3879
  });
2683
3880
  // Tool: iOS describe all (accessibility tree)
2684
3881
  server.registerTool("ios_describe_all", {
2685
- description: "Get accessibility information for the entire iOS simulator screen. Returns a nested tree of UI elements with labels, values, and frames. Requires IDB to be installed (brew install idb-companion).",
3882
+ description: "Get accessibility information for the entire iOS simulator screen. Returns a nested tree of UI elements with labels, values, and frames. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion).",
2686
3883
  inputSchema: {
2687
3884
  udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2688
3885
  }
2689
3886
  }, async ({ udid }) => {
2690
3887
  const result = await iosDescribeAll(udid);
3888
+ let text = result.success ? result.result : `Error: ${result.error}`;
3889
+ // Check for LogBox overlay
3890
+ if (result.success) {
3891
+ try {
3892
+ const logBoxState = await detectLogBox();
3893
+ if (logBoxState && logBoxState.total > 0) {
3894
+ text += formatLogBoxWarning(logBoxState);
3895
+ }
3896
+ }
3897
+ catch {
3898
+ // Non-fatal: LogBox detection failure should not break describe_all
3899
+ }
3900
+ }
2691
3901
  return {
2692
3902
  content: [
2693
3903
  {
2694
3904
  type: "text",
2695
- text: result.success ? result.result : `Error: ${result.error}`
3905
+ text
2696
3906
  }
2697
3907
  ],
2698
3908
  isError: !result.success
@@ -2700,7 +3910,7 @@ server.registerTool("ios_describe_all", {
2700
3910
  });
2701
3911
  // Tool: iOS describe point
2702
3912
  server.registerTool("ios_describe_point", {
2703
- description: "Get accessibility information for the UI element at a specific point on the iOS simulator screen. Requires IDB to be installed (brew install idb-companion).",
3913
+ description: "Get accessibility information for the UI element at a specific point on the iOS simulator screen. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion).",
2704
3914
  inputSchema: {
2705
3915
  x: z.coerce.number().describe("X coordinate in pixels"),
2706
3916
  y: z.coerce.number().describe("Y coordinate in pixels"),
@@ -2720,7 +3930,7 @@ server.registerTool("ios_describe_point", {
2720
3930
  });
2721
3931
  // Tool: iOS find element (no screenshot needed)
2722
3932
  server.registerTool("ios_find_element", {
2723
- description: "Find a UI element on iOS simulator by accessibility label or value. Returns element details including tap coordinates. Requires IDB (brew install idb-companion). Workflow: 1) wait_for_element, 2) find_element, 3) tap with returned coordinates. Prefer this over screenshots for button taps.",
3933
+ description: "Find a UI element on iOS simulator by accessibility label or value. Returns element details including tap coordinates. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion). Workflow: 1) wait_for_element, 2) find_element, 3) tap with returned coordinates. Prefer this over screenshots for button taps.",
2724
3934
  inputSchema: {
2725
3935
  label: z.string().optional().describe("Exact accessibility label match"),
2726
3936
  labelContains: z.string().optional().describe("Partial label match (case-insensitive)"),
@@ -2758,7 +3968,7 @@ server.registerTool("ios_find_element", {
2758
3968
  ` Value: "${el.value}"`,
2759
3969
  ` Type: ${el.type}`,
2760
3970
  ` Frame: {x: ${el.frame.x}, y: ${el.frame.y}, w: ${el.frame.width}, h: ${el.frame.height}}`,
2761
- ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
3971
+ ` Center (tap coords): (${el.center.x}, ${el.center.y}) — iOS points. Use tap(text="...") or tap(testID="...") for reliable targeting.`,
2762
3972
  ` Enabled: ${el.enabled}`
2763
3973
  ].join("\n");
2764
3974
  return {
@@ -2767,7 +3977,7 @@ server.registerTool("ios_find_element", {
2767
3977
  });
2768
3978
  // Tool: iOS wait for element
2769
3979
  server.registerTool("ios_wait_for_element", {
2770
- description: "Wait for a UI element to appear on iOS simulator. Polls until found or timeout. Requires IDB (brew install idb-companion). Use this FIRST after navigation to ensure screen is ready, then use find_element + tap.",
3980
+ description: "Wait for a UI element to appear on iOS simulator. Polls until found or timeout. Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion). Use this FIRST after navigation to ensure screen is ready, then use find_element + tap.",
2771
3981
  inputSchema: {
2772
3982
  label: z.string().optional().describe("Exact accessibility label match"),
2773
3983
  labelContains: z.string().optional().describe("Partial label match (case-insensitive)"),
@@ -2825,109 +4035,43 @@ server.registerTool("ios_wait_for_element", {
2825
4035
  ` Label: "${el.label}"`,
2826
4036
  ` Value: "${el.value}"`,
2827
4037
  ` Type: ${el.type}`,
2828
- ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
4038
+ ` Center (tap coords): (${el.center.x}, ${el.center.y}) — iOS points. Use tap(text="...") or tap(testID="...") for reliable targeting.`,
2829
4039
  ` Enabled: ${el.enabled}`
2830
4040
  ].join("\n");
2831
4041
  return {
2832
4042
  content: [{ type: "text", text: info }]
2833
4043
  };
2834
4044
  });
2835
- // Tool: Get debug server info
2836
- registerToolWithTelemetry("get_debug_server", {
2837
- description: "Get the debug HTTP server URL. Use this to find where you can access logs, network requests, and other debug data via HTTP.",
2838
- inputSchema: {}
2839
- }, async () => {
2840
- const port = getDebugServerPort();
2841
- if (!port) {
4045
+ // Tool: Activate Pro license
4046
+ registerToolWithTelemetry("activate_license", getActivateLicenseConfig(), handleActivateLicense);
4047
+ // Tool: Delete account
4048
+ registerToolWithTelemetry("delete_account", getDeleteAccountConfig(), handleDeleteAccount);
4049
+ // Dev-only tool: reset local telemetry data
4050
+ if (isDevMode()) {
4051
+ registerToolWithTelemetry("reset_telemetry", {
4052
+ description: "Clear local telemetry data file (/tmp/rn-devtools-telemetry.jsonl). Only available in development mode.",
4053
+ inputSchema: {},
4054
+ }, async () => {
4055
+ if (existsSync(TELEMETRY_JSONL_PATH)) {
4056
+ unlinkSync(TELEMETRY_JSONL_PATH);
4057
+ return {
4058
+ content: [{ type: "text", text: "Local telemetry data cleared." }],
4059
+ };
4060
+ }
2842
4061
  return {
2843
- content: [
2844
- {
2845
- type: "text",
2846
- text: "Debug HTTP server is not running."
2847
- }
2848
- ],
2849
- isError: true
4062
+ content: [{ type: "text", text: "No local telemetry data file found." }],
2850
4063
  };
2851
- }
2852
- const info = {
2853
- url: `http://localhost:${port}`,
2854
- endpoints: {
2855
- status: `http://localhost:${port}/api/status`,
2856
- logs: `http://localhost:${port}/api/logs`,
2857
- network: `http://localhost:${port}/api/network`,
2858
- bundleErrors: `http://localhost:${port}/api/bundle-errors`,
2859
- apps: `http://localhost:${port}/api/apps`
2860
- }
2861
- };
2862
- return {
2863
- content: [
2864
- {
2865
- type: "text",
2866
- text: `Debug HTTP server running at:\n\n${JSON.stringify(info, null, 2)}`
2867
- }
2868
- ]
2869
- };
2870
- });
2871
- // Tool: Restart HTTP server (hot-reload)
2872
- registerToolWithTelemetry("restart_http_server", {
2873
- description: "Note: HTTP server now runs in-process to share state. To apply code changes, restart the MCP session.",
2874
- inputSchema: {}
2875
- }, async () => {
2876
- const port = getDebugServerPort();
2877
- return {
2878
- content: [
2879
- {
2880
- type: "text",
2881
- text: `HTTP server is running in-process on port ${port}. To apply code changes, rebuild with 'npm run build' and restart the MCP session. The in-process mode is required for the dashboard to show logs, network requests, and connected apps.`
2882
- }
2883
- ]
2884
- };
2885
- });
2886
- /**
2887
- * Auto-connect to Metro bundler on startup
2888
- * Scans common ports and connects to any running Metro servers
2889
- */
2890
- async function autoConnectToMetro() {
2891
- console.error("[rn-ai-debugger] Auto-scanning for Metro servers...");
2892
- try {
2893
- const openPorts = await scanMetroPorts();
2894
- if (openPorts.length === 0) {
2895
- console.error("[rn-ai-debugger] No Metro servers found on startup. Use scan_metro to connect later.");
2896
- return;
2897
- }
2898
- for (const port of openPorts) {
2899
- try {
2900
- const devices = await fetchDevices(port);
2901
- const mainDevice = selectMainDevice(devices);
2902
- if (mainDevice) {
2903
- await connectToDevice(mainDevice, port);
2904
- console.error(`[rn-ai-debugger] Auto-connected to ${mainDevice.title} on port ${port}`);
2905
- // Also connect to Metro build events
2906
- try {
2907
- await connectMetroBuildEvents(port);
2908
- }
2909
- catch {
2910
- // Build events connection is optional
2911
- }
2912
- }
2913
- }
2914
- catch (error) {
2915
- console.error(`[rn-ai-debugger] Failed to auto-connect on port ${port}: ${error}`);
2916
- }
2917
- }
2918
- }
2919
- catch (error) {
2920
- console.error(`[rn-ai-debugger] Auto-connect error: ${error}`);
2921
- }
4064
+ });
2922
4065
  }
2923
4066
  // Main function
2924
4067
  async function main() {
2925
4068
  // Initialize telemetry (checks opt-out env var, loads/creates installation ID)
4069
+ // License validation is lazy — runs on first tool use via ensureLicense()
2926
4070
  initTelemetry();
2927
- // Start debug HTTP server in-process (shares state with MCP server)
2928
- // Note: Child process mode doesn't work because state (logs, network, apps) isn't shared
2929
- await startDebugHttpServer();
2930
- console.error("[rn-ai-debugger] HTTP server started in-process");
4071
+ identifyIfDevMode(getInstallationId());
4072
+ // --- Eager usage check (pre-loads usage cache for tool-level gate) ---
4073
+ const { ensureLicense } = await import("./core/license.js");
4074
+ await ensureLicense();
2931
4075
  const useHttp = process.argv.includes("--http");
2932
4076
  const httpPort = parseInt(process.env.MCP_HTTP_PORT || "8600", 10);
2933
4077
  if (useHttp) {
@@ -2997,16 +4141,38 @@ async function main() {
2997
4141
  await server.connect(transport);
2998
4142
  console.error("[rn-ai-debugger] Server started on stdio");
2999
4143
  }
3000
- // Auto-connect to Metro in background (non-blocking)
3001
- // Use setImmediate to ensure MCP server is fully ready first
3002
- setImmediate(() => {
3003
- autoConnectToMetro().catch((err) => {
3004
- console.error("[rn-ai-debugger] Auto-connect failed:", err);
3005
- });
3006
- });
3007
4144
  }
3008
4145
  main().catch((error) => {
3009
4146
  console.error("[rn-ai-debugger] Fatal error:", error);
3010
4147
  process.exit(1);
3011
4148
  });
4149
+ // Graceful shutdown: close CDP connections so the slot is freed for other sessions
4150
+ function gracefulShutdown() {
4151
+ suppressReconnection();
4152
+ cancelAllReconnectionTimers();
4153
+ for (const [key, app] of connectedApps.entries()) {
4154
+ try {
4155
+ app.ws.close();
4156
+ }
4157
+ catch {
4158
+ // Ignore close errors during shutdown
4159
+ }
4160
+ connectedApps.delete(key);
4161
+ }
4162
+ disconnectMetroBuildEvents();
4163
+ clearAllConnectionState();
4164
+ clearAllCDPMessageTimes();
4165
+ shutdownPostHog().catch(() => { });
4166
+ }
4167
+ process.on("beforeExit", () => {
4168
+ shutdownPostHog().catch(() => { });
4169
+ });
4170
+ process.on("SIGINT", () => {
4171
+ gracefulShutdown();
4172
+ process.exit(0);
4173
+ });
4174
+ process.on("SIGTERM", () => {
4175
+ gracefulShutdown();
4176
+ process.exit(0);
4177
+ });
3012
4178
  //# sourceMappingURL=index.js.map