react-native-ai-debugger 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +73 -66
  2. package/build/__tests__/integration/auto-reconnect.test.d.ts +2 -0
  3. package/build/__tests__/integration/auto-reconnect.test.d.ts.map +1 -0
  4. package/build/__tests__/integration/auto-reconnect.test.js +118 -0
  5. package/build/__tests__/integration/auto-reconnect.test.js.map +1 -0
  6. package/build/__tests__/integration/per-call-timeout.test.d.ts +2 -0
  7. package/build/__tests__/integration/per-call-timeout.test.d.ts.map +1 -0
  8. package/build/__tests__/integration/per-call-timeout.test.js +103 -0
  9. package/build/__tests__/integration/per-call-timeout.test.js.map +1 -0
  10. package/build/__tests__/setup.js +3 -5
  11. package/build/__tests__/setup.js.map +1 -1
  12. package/build/__tests__/unit/debugGlobals.test.d.ts +2 -0
  13. package/build/__tests__/unit/debugGlobals.test.d.ts.map +1 -0
  14. package/build/__tests__/unit/debugGlobals.test.js +14 -0
  15. package/build/__tests__/unit/debugGlobals.test.js.map +1 -0
  16. package/build/__tests__/unit/escapeNonAscii.test.d.ts +2 -0
  17. package/build/__tests__/unit/escapeNonAscii.test.d.ts.map +1 -0
  18. package/build/__tests__/unit/escapeNonAscii.test.js +76 -0
  19. package/build/__tests__/unit/escapeNonAscii.test.js.map +1 -0
  20. package/build/__tests__/unit/executor.test.js +59 -7
  21. package/build/__tests__/unit/executor.test.js.map +1 -1
  22. package/build/__tests__/unit/fastRefreshRecorder.test.d.ts +2 -0
  23. package/build/__tests__/unit/fastRefreshRecorder.test.d.ts.map +1 -0
  24. package/build/__tests__/unit/fastRefreshRecorder.test.js +68 -0
  25. package/build/__tests__/unit/fastRefreshRecorder.test.js.map +1 -0
  26. package/build/__tests__/unit/fastRefreshTools.test.d.ts +2 -0
  27. package/build/__tests__/unit/fastRefreshTools.test.d.ts.map +1 -0
  28. package/build/__tests__/unit/fastRefreshTools.test.js +112 -0
  29. package/build/__tests__/unit/fastRefreshTools.test.js.map +1 -0
  30. package/build/__tests__/unit/fingerprint.test.js +10 -0
  31. package/build/__tests__/unit/fingerprint.test.js.map +1 -1
  32. package/build/__tests__/unit/measureComponent.test.d.ts +2 -0
  33. package/build/__tests__/unit/measureComponent.test.d.ts.map +1 -0
  34. package/build/__tests__/unit/measureComponent.test.js +66 -0
  35. package/build/__tests__/unit/measureComponent.test.js.map +1 -0
  36. package/build/__tests__/unit/measureComponentTools.test.d.ts +2 -0
  37. package/build/__tests__/unit/measureComponentTools.test.d.ts.map +1 -0
  38. package/build/__tests__/unit/measureComponentTools.test.js +94 -0
  39. package/build/__tests__/unit/measureComponentTools.test.js.map +1 -0
  40. package/build/__tests__/unit/rnGlobalsBootstrap.test.d.ts +2 -0
  41. package/build/__tests__/unit/rnGlobalsBootstrap.test.d.ts.map +1 -0
  42. package/build/__tests__/unit/rnGlobalsBootstrap.test.js +29 -0
  43. package/build/__tests__/unit/rnGlobalsBootstrap.test.js.map +1 -0
  44. package/build/__tests__/unit/timeoutClamp.test.d.ts +2 -0
  45. package/build/__tests__/unit/timeoutClamp.test.d.ts.map +1 -0
  46. package/build/__tests__/unit/timeoutClamp.test.js +17 -0
  47. package/build/__tests__/unit/timeoutClamp.test.js.map +1 -0
  48. package/build/__tests__/unit/transportClassify.test.d.ts +2 -0
  49. package/build/__tests__/unit/transportClassify.test.d.ts.map +1 -0
  50. package/build/__tests__/unit/transportClassify.test.js +60 -0
  51. package/build/__tests__/unit/transportClassify.test.js.map +1 -0
  52. package/build/core/appDetection.d.ts +7 -0
  53. package/build/core/appDetection.d.ts.map +1 -1
  54. package/build/core/appDetection.js +38 -17
  55. package/build/core/appDetection.js.map +1 -1
  56. package/build/core/bundle.d.ts +1 -1
  57. package/build/core/bundle.d.ts.map +1 -1
  58. package/build/core/bundle.js +6 -10
  59. package/build/core/bundle.js.map +1 -1
  60. package/build/core/componentSearch.d.ts +41 -0
  61. package/build/core/componentSearch.d.ts.map +1 -0
  62. package/build/core/componentSearch.js +484 -0
  63. package/build/core/componentSearch.js.map +1 -0
  64. package/build/core/componentTree.d.ts +13 -0
  65. package/build/core/componentTree.d.ts.map +1 -0
  66. package/build/core/componentTree.js +275 -0
  67. package/build/core/componentTree.js.map +1 -0
  68. package/build/core/connection.d.ts +1 -0
  69. package/build/core/connection.d.ts.map +1 -1
  70. package/build/core/connection.js +97 -88
  71. package/build/core/connection.js.map +1 -1
  72. package/build/core/debugGlobals.d.ts +6 -0
  73. package/build/core/debugGlobals.d.ts.map +1 -0
  74. package/build/core/debugGlobals.js +336 -0
  75. package/build/core/debugGlobals.js.map +1 -0
  76. package/build/core/escapeNonAscii.d.ts +18 -0
  77. package/build/core/escapeNonAscii.d.ts.map +1 -0
  78. package/build/core/escapeNonAscii.js +352 -0
  79. package/build/core/escapeNonAscii.js.map +1 -0
  80. package/build/core/executor.d.ts +7 -206
  81. package/build/core/executor.d.ts.map +1 -1
  82. package/build/core/executor.js +10 -4333
  83. package/build/core/executor.js.map +1 -1
  84. package/build/core/fastRefreshRecorder.d.ts +23 -0
  85. package/build/core/fastRefreshRecorder.d.ts.map +1 -0
  86. package/build/core/fastRefreshRecorder.js +83 -0
  87. package/build/core/fastRefreshRecorder.js.map +1 -0
  88. package/build/core/fastRefreshTools.d.ts +19 -0
  89. package/build/core/fastRefreshTools.d.ts.map +1 -0
  90. package/build/core/fastRefreshTools.js +71 -0
  91. package/build/core/fastRefreshTools.js.map +1 -0
  92. package/build/core/guides.d.ts.map +1 -1
  93. package/build/core/guides.js +2 -1
  94. package/build/core/guides.js.map +1 -1
  95. package/build/core/index.d.ts +5 -2
  96. package/build/core/index.d.ts.map +1 -1
  97. package/build/core/index.js +5 -2
  98. package/build/core/index.js.map +1 -1
  99. package/build/core/inspector.d.ts +50 -0
  100. package/build/core/inspector.d.ts.map +1 -0
  101. package/build/core/inspector.js +604 -0
  102. package/build/core/inspector.js.map +1 -0
  103. package/build/core/jsExecute.d.ts +61 -0
  104. package/build/core/jsExecute.d.ts.map +1 -0
  105. package/build/core/jsExecute.js +679 -0
  106. package/build/core/jsExecute.js.map +1 -0
  107. package/build/core/measureComponent.d.ts +19 -0
  108. package/build/core/measureComponent.d.ts.map +1 -0
  109. package/build/core/measureComponent.js +94 -0
  110. package/build/core/measureComponent.js.map +1 -0
  111. package/build/core/measureComponentTools.d.ts +6 -0
  112. package/build/core/measureComponentTools.d.ts.map +1 -0
  113. package/build/core/measureComponentTools.js +43 -0
  114. package/build/core/measureComponentTools.js.map +1 -0
  115. package/build/core/pressables.d.ts +44 -0
  116. package/build/core/pressables.d.ts.map +1 -0
  117. package/build/core/pressables.js +1480 -0
  118. package/build/core/pressables.js.map +1 -0
  119. package/build/core/register.d.ts +8 -0
  120. package/build/core/register.d.ts.map +1 -0
  121. package/build/core/register.js +216 -0
  122. package/build/core/register.js.map +1 -0
  123. package/build/core/rnGlobalsBootstrap.d.ts +16 -0
  124. package/build/core/rnGlobalsBootstrap.d.ts.map +1 -0
  125. package/build/core/rnGlobalsBootstrap.js +101 -0
  126. package/build/core/rnGlobalsBootstrap.js.map +1 -0
  127. package/build/core/screenLayout.d.ts +53 -0
  128. package/build/core/screenLayout.d.ts.map +1 -0
  129. package/build/core/screenLayout.js +828 -0
  130. package/build/core/screenLayout.js.map +1 -0
  131. package/build/core/state.d.ts +2 -2
  132. package/build/core/state.d.ts.map +1 -1
  133. package/build/core/state.js +10 -5
  134. package/build/core/state.js.map +1 -1
  135. package/build/core/telemetry.d.ts +23 -0
  136. package/build/core/telemetry.d.ts.map +1 -1
  137. package/build/core/telemetry.js +67 -0
  138. package/build/core/telemetry.js.map +1 -1
  139. package/build/core/toolHelpers.d.ts +24 -0
  140. package/build/core/toolHelpers.d.ts.map +1 -0
  141. package/build/core/toolHelpers.js +118 -0
  142. package/build/core/toolHelpers.js.map +1 -0
  143. package/build/core/types.d.ts +18 -6
  144. package/build/core/types.d.ts.map +1 -1
  145. package/build/index.d.ts +2 -4
  146. package/build/index.d.ts.map +1 -1
  147. package/build/index.js +37 -4440
  148. package/build/index.js.map +1 -1
  149. package/build/tools/accountTools.d.ts +2 -0
  150. package/build/tools/accountTools.d.ts.map +1 -1
  151. package/build/tools/accountTools.js +5 -0
  152. package/build/tools/accountTools.js.map +1 -1
  153. package/build/tools/bundleTools.d.ts +3 -0
  154. package/build/tools/bundleTools.d.ts.map +1 -0
  155. package/build/tools/bundleTools.js +368 -0
  156. package/build/tools/bundleTools.js.map +1 -0
  157. package/build/tools/componentTools.d.ts +3 -0
  158. package/build/tools/componentTools.d.ts.map +1 -0
  159. package/build/tools/componentTools.js +704 -0
  160. package/build/tools/componentTools.js.map +1 -0
  161. package/build/tools/connectionTools.d.ts +3 -0
  162. package/build/tools/connectionTools.d.ts.map +1 -0
  163. package/build/tools/connectionTools.js +564 -0
  164. package/build/tools/connectionTools.js.map +1 -0
  165. package/build/tools/deviceTools.d.ts +3 -0
  166. package/build/tools/deviceTools.d.ts.map +1 -0
  167. package/build/tools/deviceTools.js +296 -0
  168. package/build/tools/deviceTools.js.map +1 -0
  169. package/build/tools/executionTools.d.ts +3 -0
  170. package/build/tools/executionTools.d.ts.map +1 -0
  171. package/build/tools/executionTools.js +245 -0
  172. package/build/tools/executionTools.js.map +1 -0
  173. package/build/tools/interactionTools.d.ts +3 -0
  174. package/build/tools/interactionTools.d.ts.map +1 -0
  175. package/build/tools/interactionTools.js +558 -0
  176. package/build/tools/interactionTools.js.map +1 -0
  177. package/build/tools/logTools.d.ts +3 -0
  178. package/build/tools/logTools.d.ts.map +1 -0
  179. package/build/tools/logTools.js +371 -0
  180. package/build/tools/logTools.js.map +1 -0
  181. package/build/tools/metaTools.d.ts +7 -0
  182. package/build/tools/metaTools.d.ts.map +1 -0
  183. package/build/tools/metaTools.js +205 -0
  184. package/build/tools/metaTools.js.map +1 -0
  185. package/build/tools/networkTools.d.ts +3 -0
  186. package/build/tools/networkTools.d.ts.map +1 -0
  187. package/build/tools/networkTools.js +478 -0
  188. package/build/tools/networkTools.js.map +1 -0
  189. package/build/tools/reduxTools.d.ts +3 -0
  190. package/build/tools/reduxTools.d.ts.map +1 -0
  191. package/build/tools/reduxTools.js +83 -0
  192. package/build/tools/reduxTools.js.map +1 -0
  193. package/build/tools/screenshotTools.d.ts +3 -0
  194. package/build/tools/screenshotTools.d.ts.map +1 -0
  195. package/build/tools/screenshotTools.js +606 -0
  196. package/build/tools/screenshotTools.js.map +1 -0
  197. package/package.json +1 -1
package/build/index.js CHANGED
@@ -3,97 +3,26 @@ 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";
7
- import { z } from "zod";
8
- import { getGuideOverview, getGuideByTopic, getAvailableTopics, DECISION_TREE } from "./core/guides.js";
9
- import { getLicenseStatus, getDashboardUrl, getUsageInfo, getPricingInfo, formatPlanPrice } from "./core/license.js";
10
- import { API_BASE_URL } from "./core/config.js";
11
- import { getPostHogClient, identifyIfDevMode, shutdownPostHog } from "./core/posthog.js";
12
- import { UserInputError } from "./core/errors.js";
13
- import { clearFocusedInput, dismissKeyboard, inputTextWithReplace } from "./core/focusedInputTools.js";
14
- import { getInstallationId, getServerVersion, getPackageName, isDevMode, TELEMETRY_JSONL_PATH, categorizeError } from "./core/telemetry.js";
15
- import { isSDKInstalled, querySDKNetwork, getSDKNetworkEntry, getSDKNetworkStats, clearSDKNetwork, querySDKConsole, getSDKConsoleStats, clearSDKConsole } from "./core/sdkBridge.js";
16
- import { reduxDispatch, reduxGetState } from "./core/redux.js";
17
- import { tap } from "./pro/tap.js";
18
- import { getActivateLicenseConfig, handleActivateLicense, getDeleteAccountConfig, handleDeleteAccount, } from "./tools/accountTools.js";
19
- import { logBuffers, networkBuffers, getLogBuffer, getNetworkBuffer, getTotalLogCount, getConnectedAppByDevice, getConnectedAppBySimulatorUdid, getConnectedAppByAndroidDeviceId, LogBuffer, NetworkBuffer, bundleErrorBuffer, imageBuffer, connectedApps, getActiveOrBootedSimulatorUdid, scanMetroPorts, fetchDevices, filterDebuggableDevices, connectToDevice, getConnectedApps, executeInApp, listDebugGlobals, inspectGlobal, reloadApp,
20
- // React Component Inspection
21
- getComponentTree, getScreenLayout, getPressableElements, inspectComponent, findComponents, inspectAtPoint, toggleElementInspector, getInspectorSelection, getInspectorSelectionAtPoint, getFirstConnectedApp, getLogs, searchLogs, getLogSummary, getNetworkRequests, searchNetworkRequests, getNetworkStats, formatRequestDetails,
22
- // Connection state
23
- getAllConnectionStates, getAllConnectionMetadata, getRecentGaps, formatDuration, cancelAllReconnectionTimers, cancelReconnectionTimer, clearAllConnectionState, clearAllCDPMessageTimes, suppressReconnection, suppressReconnectionForKey, clearReconnectionSuppression, purgeStaleConnectionsForPorts,
24
- // Context health tracking
25
- getContextHealth,
26
- // Connection resilience
27
- ensureConnection, checkAndEnsureConnection, getPassiveConnectionStatus,
28
- // Bundle (Metro build errors)
29
- connectMetroBuildEvents, disconnectMetroBuildEvents, getBundleErrors, getBundleStatusWithErrors, checkMetroState,
30
- // Error screen parsing (OCR fallback)
31
- parseErrorScreenText, formatParsedError,
32
- // OCR
33
- recognizeText, inferIOSDevicePixelRatio,
34
- // Android
35
- listAndroidDevices, androidScreenshot, androidInstallApp, androidLaunchApp, androidListPackages,
36
- // Android UI Input (Phase 2)
37
- ANDROID_KEY_EVENTS, androidLongPress, androidSwipe, androidInputText, androidKeyEvent, androidGetScreenSize, androidGetDensity, androidGetStatusBarHeight,
38
- // iOS
39
- listIOSSimulators, iosScreenshot, iosInstallApp, iosLaunchApp, iosOpenUrl, iosTerminateApp, iosBootSimulator,
40
- // iOS UI driver tools
41
- isUiDriverAvailable, iosButton, iosInputText, iosDescribeAll, detectIOSSystemOverlay, formatIOSSystemOverlayWarning, IOS_BUTTON_TYPES, getDevicePixelRatio, getIOSSafeAreaTop,
42
- // Telemetry
43
- initTelemetry, trackToolInvocation, getTargetPlatform,
44
- // Format utilities (TONL)
45
- formatLogsAsTonl, formatNetworkAsTonl,
46
- // LogBox detection & control
47
- detectLogBox, formatLogBoxWarning, dismissLogBox, formatDismissedEntries, pushLogBox, addLogBoxIgnorePatterns, getLastLogBoxError, verifyLogPipeline, formatIssueBody, buildGitHubUrl, shouldShowFeedbackHint, markFeedbackHintShown,
48
- // Native-only hints — shown when Metro-required tools are called without a connection
49
- hasMetro, metroMissingHintIfAbsent, } from "./core/index.js";
50
- // Helper: resolve log buffer for a device (or create a merged buffer from all devices)
51
- function resolveLogBuffer(device) {
52
- if (device) {
53
- const app = getConnectedAppByDevice(device);
54
- if (!app)
55
- throw new UserInputError(`No connected device matches "${device}"`);
56
- const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
57
- return getLogBuffer(deviceName);
58
- }
59
- // Merge all logs into a temporary buffer for read operations
60
- const merged = new LogBuffer(5000);
61
- for (const buffer of logBuffers.values()) {
62
- for (const entry of buffer.getAll()) {
63
- merged.add(entry);
64
- }
65
- }
66
- return merged;
67
- }
68
- // Helper: resolve network buffer for a device (or create a merged buffer from all devices)
69
- function resolveNetworkBuffer(device) {
70
- if (device) {
71
- const app = getConnectedAppByDevice(device);
72
- if (!app)
73
- throw new UserInputError(`No connected device matches "${device}"`);
74
- const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
75
- return getNetworkBuffer(deviceName);
76
- }
77
- // Merge all network requests into a temporary buffer for read operations.
78
- // Key by the request's own id so get(requestId) works — earlier this used
79
- // Math.random() which made every direct lookup miss while listings still
80
- // appeared correct (listings render req.requestId from the value).
81
- // Multiple devices can share a request id; suffix with deviceName for
82
- // collisions so we don't lose entries.
83
- const merged = new NetworkBuffer(5000);
84
- const seen = new Set();
85
- for (const [deviceName, buffer] of networkBuffers.entries()) {
86
- for (const req of buffer.getAll({})) {
87
- let key = req.requestId;
88
- if (seen.has(key))
89
- key = `${req.requestId}@${deviceName}`;
90
- seen.add(key);
91
- merged.set(key, req);
92
- }
93
- }
94
- return merged;
95
- }
96
- // Create MCP server
6
+ import { DECISION_TREE } from "./core/guides.js";
7
+ import { identifyIfDevMode, shutdownPostHog } from "./core/posthog.js";
8
+ import { getInstallationId, isDevMode, initTelemetry } from "./core/telemetry.js";
9
+ import { connectedApps, cancelAllReconnectionTimers, clearAllConnectionState, clearAllCDPMessageTimes, suppressReconnection, disconnectMetroBuildEvents, } from "./core/index.js";
10
+ import { installToolRegistryInterceptor, toolRegistry } from "./core/register.js";
11
+ import { registerAccountTools } from "./tools/accountTools.js";
12
+ import { registerMetaTools } from "./tools/metaTools.js";
13
+ import { registerReduxTools } from "./tools/reduxTools.js";
14
+ import { registerExecutionTools } from "./tools/executionTools.js";
15
+ import { registerLogTools } from "./tools/logTools.js";
16
+ import { registerNetworkTools } from "./tools/networkTools.js";
17
+ import { registerBundleTools } from "./tools/bundleTools.js";
18
+ import { registerDeviceTools } from "./tools/deviceTools.js";
19
+ import { registerConnectionTools } from "./tools/connectionTools.js";
20
+ import { registerScreenshotTools } from "./tools/screenshotTools.js";
21
+ import { registerInteractionTools } from "./tools/interactionTools.js";
22
+ import { registerComponentTools } from "./tools/componentTools.js";
23
+ // Re-export so tests (src/__tests__/unit/toolDescriptions.test.ts) can enumerate
24
+ // registered tools without booting the server.
25
+ export { toolRegistry };
97
26
  const server = new McpServer({
98
27
  name: "ExecBro (Mobile DevTools)",
99
28
  version: "1.0.0"
@@ -106,4363 +35,31 @@ const server = new McpServer({
106
35
  "Call get_usage_guide with no arguments for the same decision tree plus a summary of every guide."
107
36
  ].join("\n")
108
37
  });
109
- // ============================================================================
110
- // Telemetry Wrapper
111
- // ============================================================================
112
- /**
113
- * Parse JPEG dimensions from a raw buffer by scanning for the SOF marker.
114
- * Only needs the first ~2KB of the image to find dimensions.
115
- */
116
- function getJpegDimensions(buffer) {
117
- if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8)
118
- return null;
119
- let offset = 2;
120
- while (offset < buffer.length - 9) {
121
- if (buffer[offset] !== 0xff)
122
- return null;
123
- const marker = buffer[offset + 1];
124
- // SOF markers: C0-CF except C4 (DHT) and CC (DAC)
125
- if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xcc) {
126
- const height = buffer.readUInt16BE(offset + 5);
127
- const width = buffer.readUInt16BE(offset + 7);
128
- return { width, height };
129
- }
130
- // Skip segment (read its length)
131
- const segLength = buffer.readUInt16BE(offset + 2);
132
- offset += 2 + segLength;
133
- }
134
- return null;
135
- }
136
- /**
137
- * Estimate how many tokens an image will consume in Claude's vision encoder.
138
- * Per Anthropic docs, Claude auto-resizes images to fit within:
139
- * 1) 1568px on any side, AND
140
- * 2) ~1.15 megapixels total (whichever is hit first)
141
- * Then tokens ≈ (width * height) / 750 (capped at ~1,600 per image).
142
- * We only decode the first ~2KB of the base64 string to read JPEG dimensions.
143
- */
144
- function estimateImageTokens(base64Data) {
145
- try {
146
- // Decode only the first ~2KB (2732 base64 chars ≈ 2048 bytes) to find JPEG header
147
- const headerBase64 = base64Data.slice(0, 2732);
148
- const buffer = Buffer.from(headerBase64, "base64");
149
- const dims = getJpegDimensions(buffer);
150
- if (!dims)
151
- return Math.ceil(base64Data.length / 4); // fallback for non-JPEG
152
- let { width, height } = dims;
153
- // Step 1: Claude resizes to fit within 1568px on any side
154
- const MAX_CLAUDE_DIM = 1568;
155
- if (width > MAX_CLAUDE_DIM || height > MAX_CLAUDE_DIM) {
156
- const scale = MAX_CLAUDE_DIM / Math.max(width, height);
157
- width = Math.round(width * scale);
158
- height = Math.round(height * scale);
159
- }
160
- // Step 2: Claude further resizes to fit within ~1.15 megapixels
161
- const MAX_PIXELS = 1_150_000;
162
- if (width * height > MAX_PIXELS) {
163
- const scale = Math.sqrt(MAX_PIXELS / (width * height));
164
- width = Math.round(width * scale);
165
- height = Math.round(height * scale);
166
- }
167
- return Math.ceil((width * height) / 750);
168
- }
169
- catch {
170
- return Math.ceil(base64Data.length / 4); // fallback
171
- }
172
- }
173
- // Tools that do NOT require an active Metro connection — excluded from feedback hint trigger
174
- const NON_METRO_TOOLS = new Set([
175
- "scan_metro",
176
- "connect_metro",
177
- "disconnect_metro",
178
- "ensure_connection",
179
- "get_connection_status",
180
- "get_license_status",
181
- "activate_license",
182
- "delete_account",
183
- "get_usage_guide",
184
- "get_apps",
185
- "list_ios_simulators",
186
- "list_android_devices",
187
- "ios_boot_simulator",
188
- "ios_launch_app",
189
- "android_launch_app",
190
- "ios_install_app",
191
- "android_install_app",
192
- "send_feedback"
193
- ]);
194
- // Banner helpers for platform-specific tool descriptions. Appended after the
195
- // verbatim first sentence of every ios_*/android_* tool to steer agents toward
196
- // cross-platform primaries (tap, get_screen_layout, etc) unless native-only
197
- // behavior is required. See src/core/nativeOnlyHints.ts for the complementary
198
- // runtime hint shown when Metro is absent.
199
- const platformFallbackBanner = (prefer) => `\n[PLATFORM FALLBACK — prefer ${prefer} unless you specifically need native-only behavior]`;
200
- const platformUniqueBanner = (useCase) => `\n[PLATFORM-SPECIFIC — no cross-platform equivalent; use when ${useCase}]`;
201
- const primaryInteractionBanner = () => `\n[PRIMARY INTERACTION TOOL — works on iOS and Android; prefer over ios_*/android_* siblings]`;
202
- // Registry for dev meta-tool — stores handlers and configs for dynamic dispatch.
203
- // Also exported so unit tests can enumerate every registered tool without booting
204
- // the server. Populated by registerToolWithTelemetry AND by the server.registerTool
205
- // interceptor installed below, so it captures every registration site.
206
- /* eslint-disable @typescript-eslint/no-explicit-any */
207
- export const toolRegistry = new Map();
208
- // Interceptor: capture every direct server.registerTool call into toolRegistry so
209
- // tests and the dev meta-tool see the full surface (including platform-native tools
210
- // and the dev meta-tool itself that bypass registerToolWithTelemetry).
211
- const _originalRegisterTool = server.registerTool.bind(server);
212
- server.registerTool = (name, config, handler) => {
213
- toolRegistry.set(name, { config, handler });
214
- return _originalRegisterTool(name, config, handler);
215
- };
216
- function registerToolWithTelemetry(toolName, config, handler, emptyResultDetector) {
217
- toolRegistry.set(toolName, { config, handler });
218
- server.registerTool(toolName, config, async (args) => {
219
- // --- Usage limit check (runs on every tool call) ---
220
- // Promo is client-enforced: while promotionalPeriodEndsAt is in the future,
221
- // never block — even if the backend cached canUse=false before the promo
222
- // started, or if a backend bug flips canUse incorrectly.
223
- const usageInfo = getUsageInfo();
224
- const inPromo = !!usageInfo?.promotionalPeriodEndsAt
225
- && new Date(usageInfo.promotionalPeriodEndsAt).getTime() > Date.now();
226
- if (!inPromo && usageInfo && !usageInfo.canUse) {
227
- const proPricing = getPricingInfo()?.pro;
228
- const upgradeClause = proPricing
229
- ? `Upgrade to Pro for ${formatPlanPrice(proPricing)} unlimited usage`
230
- : `Upgrade to Pro for unlimited usage`;
231
- const message = `Monthly free limit reached (${usageInfo.used}/${usageInfo.limit}). ${upgradeClause} at ${API_BASE_URL}/pricing`;
232
- return { content: [{ type: "text", text: message }] };
233
- }
234
- const startTime = Date.now();
235
- let success = true;
236
- let errorMessage;
237
- let errorContext;
238
- let inputTokens;
239
- let outputTokens;
240
- let emptyResult;
241
- let meaningful;
242
- let changeRate;
243
- let tapStrategy;
244
- let iosDriver;
245
- let responsePreview;
246
- let emptyReason;
247
- let artifactKey;
248
- let ocrClosestMatch;
249
- let fiberPressableCount;
250
- let accessibilityMatchCount;
251
- let appRoute;
252
- try {
253
- inputTokens = Math.ceil(JSON.stringify(args).length / 4);
254
- }
255
- catch {
256
- /* circular refs — leave undefined */
257
- }
258
- try {
259
- const result = await handler(args);
260
- // Check if result indicates an error
261
- if (result?.isError) {
262
- success = false;
263
- // Prefer concise _errorMessage over full response text (which may be large JSON)
264
- errorMessage = result._errorMessage || result.content?.[0]?.text || "Unknown error";
265
- }
266
- // Always propagate _errorContext when the tool provides it (e.g. tap predicate
267
- // for unmeaningful outcomes where isError is false but we still want triage context).
268
- if (result?._errorContext) {
269
- errorContext = result._errorContext;
270
- }
271
- // Check for empty result (only on success, only if detector provided)
272
- if (success && emptyResultDetector) {
273
- try {
274
- emptyResult = emptyResultDetector(result);
275
- }
276
- catch {
277
- // Detector failure should never affect tool execution
278
- }
279
- }
280
- // Extract meaningfulness data if provided (tap tool verification)
281
- if (result?._meaningful !== undefined)
282
- meaningful = result._meaningful;
283
- if (result?._changeRate !== undefined)
284
- changeRate = result._changeRate;
285
- if (result?._tapStrategy)
286
- tapStrategy = result._tapStrategy;
287
- if (result?._iosDriver)
288
- iosDriver = result._iosDriver;
289
- if (result?._emptyReason)
290
- emptyReason = result._emptyReason;
291
- if (result?._artifactKey)
292
- artifactKey = result._artifactKey;
293
- if (result?._ocrClosestMatch)
294
- ocrClosestMatch = result._ocrClosestMatch;
295
- if (result?._fiberPressableCount)
296
- fiberPressableCount = result._fiberPressableCount;
297
- if (result?._accessibilityMatchCount)
298
- accessibilityMatchCount = result._accessibilityMatchCount;
299
- if (result?._appRoute)
300
- appRoute = result._appRoute;
301
- if (Array.isArray(result?.content)) {
302
- let totalTokens = 0;
303
- for (const item of result.content) {
304
- if (item.type === "text" && typeof item.text === "string") {
305
- totalTokens += Math.ceil(item.text.length / 4);
306
- }
307
- else if (item.type === "image" && typeof item.data === "string") {
308
- totalTokens += estimateImageTokens(item.data);
309
- }
310
- }
311
- if (totalTokens > 0)
312
- outputTokens = totalTokens;
313
- }
314
- // Capture response text preview for local dev dashboard
315
- if (Array.isArray(result?.content)) {
316
- const textParts = result.content
317
- .filter((item) => item.type === "text")
318
- .map((item) => item.text);
319
- if (textParts.length > 0) {
320
- responsePreview = textParts.join("\n").substring(0, 2000);
321
- }
322
- }
323
- // First-install feedback hint — fires once on first successful Metro-connected tool
324
- if (!NON_METRO_TOOLS.has(toolName) && shouldShowFeedbackHint()) {
325
- markFeedbackHintShown();
326
- // Fire-and-forget — don't block the tool response
327
- 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, ExecBro developer.", "warning", true, "logbox").catch(() => {
328
- // Non-fatal — hint delivery failure should not affect tool execution
329
- });
330
- }
331
- return result;
332
- }
333
- catch (error) {
334
- success = false;
335
- errorMessage = error instanceof Error ? error.message : String(error);
336
- // H2 (Step 9): UserInputError marks agent-input mistakes (unknown
337
- // device, missing predicate, ambiguous match). They flow through
338
- // telemetry's trackToolInvocation in the finally block; we just
339
- // skip the dedicated error-tracking pipe so the dashboard surfaces
340
- // real product bugs rather than validation noise.
341
- if (!(error instanceof UserInputError)) {
342
- getPostHogClient()?.captureException(error, getInstallationId(), { tool: toolName, server_version: getServerVersion(), package_name: getPackageName() });
343
- }
344
- throw error;
345
- }
346
- finally {
347
- const duration = Date.now() - startTime;
348
- trackToolInvocation(toolName, success, duration, errorMessage, errorContext, inputTokens, outputTokens, getTargetPlatform(), emptyResult, meaningful, changeRate, tapStrategy, iosDriver, responsePreview, emptyReason, artifactKey, ocrClosestMatch, fiberPressableCount, accessibilityMatchCount, appRoute);
349
- // Classify this invocation's platform kind so PostHog breakdowns can split RN vs Native.
350
- // RN: any connected app has appDetection. Native: tool name prefixed ios_/android_. Else: null.
351
- let platformKind = null;
352
- for (const app of connectedApps.values()) {
353
- if (app.appDetection) {
354
- platformKind = "rn";
355
- break;
356
- }
357
- }
358
- if (!platformKind) {
359
- if (toolName.startsWith("ios_") || toolName.startsWith("android_"))
360
- platformKind = "native";
361
- }
362
- getPostHogClient()?.capture({
363
- distinctId: getInstallationId(),
364
- event: toolName,
365
- properties: {
366
- success,
367
- duration,
368
- server_version: getServerVersion(),
369
- package_name: getPackageName(),
370
- ...(errorMessage && { error: errorMessage.substring(0, 200) }),
371
- ...(errorMessage && { error_category: categorizeError(errorMessage) }),
372
- ...(getTargetPlatform() && { platform: getTargetPlatform() }),
373
- ...(platformKind && { platform_kind: platformKind }),
374
- ...(tapStrategy && { tap_strategy: tapStrategy }),
375
- ...(meaningful !== undefined && { meaningful }),
376
- ...(changeRate !== undefined && { change_rate: changeRate }),
377
- ...(iosDriver && { ios_driver: iosDriver }),
378
- ...(emptyResult !== undefined && { empty_result: emptyResult }),
379
- ...(emptyReason && { empty_reason: emptyReason }),
380
- },
381
- });
382
- }
383
- });
384
- }
385
- /* eslint-enable @typescript-eslint/no-explicit-any */
386
- // Tool: Usage guide for agents
387
- registerToolWithTelemetry("get_usage_guide", {
388
- description: "Get recommended workflows and best practices for using the debugging tools. Call without parameters to see all available topics with short descriptions. Call with a topic parameter to get the full guide for that topic.",
389
- inputSchema: {
390
- topic: z
391
- .string()
392
- .optional()
393
- .describe("Topic to get the full guide for. Available topics: setup, inspect, layout, interact, logs, network, state, bundle. Omit to see the overview of all topics.")
394
- }
395
- }, async ({ topic }) => {
396
- if (!topic) {
397
- return {
398
- content: [{ type: "text", text: getGuideOverview() }]
399
- };
400
- }
401
- const guide = getGuideByTopic(topic);
402
- if (!guide) {
403
- const available = getAvailableTopics().join(", ");
404
- return {
405
- content: [
406
- {
407
- type: "text",
408
- text: `Unknown topic: "${topic}". Available topics: ${available}`
409
- }
410
- ],
411
- isError: true
412
- };
413
- }
414
- return {
415
- content: [{ type: "text", text: guide }]
416
- };
417
- });
418
- // Tool: Scan for Metro servers
419
- registerToolWithTelemetry("scan_metro", {
420
- description: "Scan for running Metro bundler servers and automatically connect to any found React Native apps. This is typically the FIRST tool to call when starting a debugging session - it establishes the connection needed for other tools like get_logs, list_debug_globals, execute_in_app, and reload_app.\n" +
421
- "PURPOSE: Discover Metro on common ports (8081, 8082, 19000-19002) and auto-connect all React Native debugger targets it advertises.\n" +
422
- "WHEN TO USE: At the start of any session, or after the user restarts Metro / boots a new simulator.\n" +
423
- "WORKFLOW: scan_metro -> get_apps -> get_logs / ios_screenshot / tap.\n" +
424
- "GOOD: scan_metro()\n" +
425
- "BAD: scan_metro() called repeatedly in a loop — use ensure_connection to re-verify an existing connection.\n" +
426
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
427
- inputSchema: {
428
- startPort: z.coerce.number().optional().default(8081).describe("Start port for scanning (default: 8081)"),
429
- endPort: z.coerce.number().optional().default(19002).describe("End port for scanning (default: 19002)")
430
- }
431
- }, async ({ startPort, endPort }) => {
432
- // Clear reconnection suppression (in case user previously called disconnect_metro)
433
- clearReconnectionSuppression();
434
- const openPorts = await scanMetroPorts(startPort, endPort);
435
- if (openPorts.length === 0) {
436
- return {
437
- content: [
438
- {
439
- type: "text",
440
- text: "No Metro servers found. Make sure Metro bundler is running (npm start or expo start)."
441
- }
442
- ]
443
- };
444
- }
445
- // Phase 1: Fetch devices from all ports first
446
- const portDevices = new Map();
447
- for (const port of openPorts) {
448
- const devices = await fetchDevices(port);
449
- const debuggable = filterDebuggableDevices(devices);
450
- if (debuggable.length > 0) {
451
- portDevices.set(port, debuggable);
452
- }
453
- }
454
- // Phase 1.5: Purge stale connections for scanned ports
455
- // When Metro restarts with a different app, device IDs change.
456
- // Old connections remain in connectedApps — remove them before connecting new devices.
457
- const purged = purgeStaleConnectionsForPorts(portDevices);
458
- // Phase 2: Assign each device to the best port
459
- // If a device appears on multiple ports, prefer the port where it has
460
- // the fewest OTHER unique devices (i.e. its dedicated Metro server)
461
- const devicePortAssignment = new Map(); // deviceName -> best port
462
- const allDeviceNames = new Map();
463
- for (const [port, devices] of portDevices) {
464
- for (const device of devices) {
465
- const name = device.deviceName || device.title;
466
- const entry = allDeviceNames.get(name);
467
- if (entry) {
468
- entry.ports.push(port);
469
- }
470
- else {
471
- allDeviceNames.set(name, { device, ports: [port] });
472
- }
473
- }
474
- }
475
- for (const [name, { ports }] of allDeviceNames) {
476
- if (ports.length === 1) {
477
- devicePortAssignment.set(name, ports[0]);
478
- }
479
- else {
480
- // Prefer the port with fewer OTHER unique devices (the dedicated Metro)
481
- let bestPort = ports[0];
482
- let fewestOthers = Infinity;
483
- for (const port of ports) {
484
- const othersOnPort = (portDevices.get(port) || [])
485
- .filter(d => (d.deviceName || d.title) !== name).length;
486
- if (othersOnPort < fewestOthers) {
487
- fewestOthers = othersOnPort;
488
- bestPort = port;
489
- }
490
- }
491
- devicePortAssignment.set(name, bestPort);
492
- }
493
- }
494
- // Phase 3: Connect devices to their assigned ports
495
- const results = [];
496
- if (purged.length > 0) {
497
- results.push(`Purged ${purged.length} stale connection(s): ${purged.join(", ")}`);
498
- }
499
- for (const port of openPorts) {
500
- const devices = portDevices.get(port);
501
- if (!devices) {
502
- results.push(`Port ${port}: No debuggable devices found`);
503
- continue;
504
- }
505
- results.push(`Port ${port}: Found ${devices.length} device(s)`);
506
- for (const device of devices) {
507
- const name = device.deviceName || device.title;
508
- const assignedPort = devicePortAssignment.get(name);
509
- if (assignedPort !== port) {
510
- results.push(` - ${name}: Skipped (assigned to port ${assignedPort})`);
511
- continue;
512
- }
513
- try {
514
- const connectionResult = await connectToDevice(device, port);
515
- const isStale = connectionResult.includes("stale CDP target");
516
- const prefix = isStale ? " - STALE" : " -";
517
- results.push(`${prefix} ${connectionResult}`);
518
- }
519
- catch (error) {
520
- results.push(` - ${name}: Failed - ${error}`);
521
- }
522
- }
523
- // Connect to Metro build events for this port
524
- try {
525
- await connectMetroBuildEvents(port);
526
- results.push(` - Connected to Metro build events`);
527
- }
528
- catch {
529
- // Build events connection is optional
530
- }
531
- }
532
- // Advisory if any CDP targets failed the liveness probe
533
- const staleCount = results.filter((r) => r.startsWith(" - STALE")).length;
534
- if (staleCount > 0) {
535
- results.push("");
536
- results.push(`Note: ${staleCount} stale CDP target(s) advertised by Metro were rejected.`);
537
- results.push("If you expected one of these to be live, restart Metro: the target is a leftover from a previous device/app session.");
538
- }
539
- // Proactive check: warn if iOS UI driver is missing
540
- const hasIosDevice = Array.from(allDeviceNames.values()).some(({ device }) => {
541
- const title = (device.title || "").toLowerCase();
542
- return title.includes("iphone") || title.includes("ipad") || title.includes("ios");
543
- });
544
- if (hasIosDevice) {
545
- const uiDriverOk = await isUiDriverAvailable();
546
- if (!uiDriverOk) {
547
- results.push("");
548
- results.push("⚠️ WARNING: iOS UI driver is NOT installed. The `tap` tool and other UI-interaction tools will fail.");
549
- results.push(" Install the default driver: brew install cameroncooke/axe/axe");
550
- results.push(" Alternative: brew install idb-companion (set IOS_DRIVER=idb to use it)");
551
- }
552
- }
553
- return {
554
- content: [
555
- {
556
- type: "text",
557
- text: `Metro scan results:\n${results.join("\n")}`
558
- }
559
- ]
560
- };
561
- });
562
- // Tool: Ensure connection health
563
- registerToolWithTelemetry("ensure_connection", {
564
- 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.\n" +
565
- "PURPOSE: Health-check the existing CDP connection and transparently reconnect if it has gone stale, without rescanning all Metro ports.\n" +
566
- "WHEN TO USE: After a suspected disconnect (silent gaps, reload_app, app crash) or before long-running flows where a mid-flow drop would be costly. Cheaper than scan_metro when you already connected once this session.\n" +
567
- "WORKFLOW: scan_metro (once) -> ensure_connection(healthCheck=true) -> resume tool calls. Use forceRefresh=true if the first probe still looks dead.\n" +
568
- "GOOD: ensure_connection({ healthCheck: true })\n" +
569
- "BAD: ensure_connection() before scan_metro has ever run — call scan_metro first.\n" +
570
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
571
- inputSchema: {
572
- port: z.coerce.number().optional().describe("Metro port (default: auto-detect)"),
573
- healthCheck: z
574
- .boolean()
575
- .optional()
576
- .default(true)
577
- .describe("Run health check to verify page context is responsive (default: true)"),
578
- forceRefresh: z
579
- .boolean()
580
- .optional()
581
- .default(false)
582
- .describe("Force close existing connection and reconnect (default: false)")
583
- }
584
- }, async ({ port, healthCheck, forceRefresh }) => {
585
- const result = await ensureConnection({ port, healthCheck, forceRefresh });
586
- if (!result.connected) {
587
- return {
588
- content: [
589
- {
590
- type: "text",
591
- 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."
592
- }
593
- ],
594
- isError: true
595
- };
596
- }
597
- const lines = [];
598
- lines.push("=== Connection Ensured ===\n");
599
- if (result.connectionInfo) {
600
- lines.push(`Device: ${result.connectionInfo.deviceTitle}`);
601
- lines.push(`Port: ${result.connectionInfo.port}`);
602
- lines.push(`Uptime: ${result.connectionInfo.uptime}`);
603
- if (result.connectionInfo.contextId !== null) {
604
- lines.push(`Context ID: ${result.connectionInfo.contextId}`);
605
- }
606
- }
607
- lines.push("");
608
- lines.push(`Reconnected: ${result.wasReconnected ? "Yes" : "No"}`);
609
- lines.push(`Health Check: ${result.healthCheckPassed ? "PASSED" : "FAILED"}`);
610
- if (!result.healthCheckPassed) {
611
- lines.push("");
612
- lines.push("Warning: Health check failed. The page context may be stale.");
613
- lines.push("Consider using forceRefresh=true or reload_app to get a fresh context.");
614
- }
615
- return {
616
- content: [{ type: "text", text: lines.join("\n") }]
617
- };
618
- });
619
- // Tool: Get connected apps
620
- registerToolWithTelemetry("get_apps", {
621
- description: "List currently connected React Native apps and their connection status. If no apps are connected, run scan_metro first to establish a connection.\n" +
622
- "PURPOSE: Enumerate active debug targets with device names, platforms, ports, and detected RN/Expo versions so you can target the right one.\n" +
623
- "WHEN TO USE: After scan_metro to confirm what connected, or before passing a device=\"...\" filter to another tool.\n" +
624
- "WORKFLOW: scan_metro -> get_apps -> get_logs / ios_screenshot / tap (with device=\"...\" if multiple).\n" +
625
- "LIMITATIONS: Only lists devices the MCP has successfully connected to — stale targets don't appear here, use get_connection_status for health details.\n" +
626
- "GOOD: get_apps()\n" +
627
- "BAD: Calling get_apps in a tight loop — the list doesn't change without a scan_metro or disconnect_metro.\n" +
628
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
629
- inputSchema: {}
630
- }, async () => {
631
- const apps = getConnectedApps();
632
- if (apps.length === 0) {
633
- return {
634
- content: [
635
- {
636
- type: "text",
637
- text: "No connected devices. Run scan_metro to discover and connect to Metro servers."
638
- }
639
- ]
640
- };
641
- }
642
- const deviceLines = apps
643
- .filter(({ isConnected }) => isConnected)
644
- .map(({ app }, i) => {
645
- const name = app.deviceInfo.deviceName || app.deviceInfo.title;
646
- const appId = app.deviceInfo.appId || app.deviceInfo.title.split(" (")[0] || "unknown";
647
- const lines = [` ${i + 1}. ${name} — ${appId} (${app.platform}, port ${app.port})`];
648
- if (app.appDetection) {
649
- const d = app.appDetection;
650
- const version = d.reactNativeVersion !== "unknown" ? `RN ${d.reactNativeVersion}` : "RN unknown";
651
- const expo = d.expoSdkVersion ? `, Expo SDK ${d.expoSdkVersion}` : "";
652
- lines.push(` Environment: ${version}, ${d.architecture} arch, ${d.jsEngine}, ${app.platform} ${d.osVersion}${expo}`);
653
- }
654
- return lines.join("\n");
655
- });
656
- const text = [
657
- `Connected devices:`,
658
- ...deviceLines,
659
- ``,
660
- `Use device="${apps[0].app.deviceInfo.deviceName}" to target a specific device.`,
661
- ``,
662
- `Total logs in buffer: ${getTotalLogCount()}`
663
- ].join("\n");
664
- return {
665
- content: [{ type: "text", text }]
666
- };
667
- });
668
- // Tool: Unified tap — tries fiber, accessibility, OCR, coordinate strategies
669
- registerToolWithTelemetry("tap", {
670
- description: "Tap a UI element. Automatically tries multiple strategies: fiber tree (React), accessibility tree (native), and OCR (visual)." +
671
- primaryInteractionBanner() + "\n" +
672
- "PURPOSE: Single unified tap entry point — resolves text/testID/component/coordinates into a real touch event on the correct device.\n" +
673
- "WHEN TO USE: Any time you need to press a button, focus an input, open a menu, or verify a handler fires. Prefer testID, then text, then component, then (x,y) from a screenshot's pressables list.\n" +
674
- "WORKFLOW: ios_screenshot or android_screenshot -> tap(testID=\"...\") | tap(text=\"...\") | tap(x, y) -> screenshot again to verify. Use burst=true when meaningful=false but visual feedback looks transient.\n" +
675
- "LIMITATIONS: iOS needs AXe (brew install cameroncooke/axe/axe) or IDB for accessibility/coordinate taps. Non-ASCII text skips fiber (Hermes); prefer testID. When iOS AND Android are connected, pass platform explicitly.\n" +
676
- "GOOD: tap({ testID: \"login-btn\" }); tap({ text: \"Submit\" }); tap({ x: 300, y: 600 }); tap({ x: 300, y: 600, native: true, platform: \"android\" })\n" +
677
- "BAD: tap({ text: \"\" }) or tap({ x: 0, y: 0 }) — missing a target. tap({ text: \"Submit\" }) without first screenshotting an ambiguous screen.\n" +
678
- "SEE ALSO: call get_usage_guide(topic=\"interact\") for the full device-interaction playbook.",
679
- inputSchema: {
680
- text: z
681
- .string()
682
- .optional()
683
- .describe("Visible text to match (case-insensitive substring). ASCII only for fiber strategy; OCR handles non-ASCII."),
684
- testID: z
685
- .string()
686
- .optional()
687
- .describe("Exact match on the element's testID prop."),
688
- component: z
689
- .string()
690
- .optional()
691
- .describe("Component name match (case-insensitive substring, e.g. 'Button', 'MenuItem')."),
692
- index: z.coerce
693
- .number()
694
- .optional()
695
- .describe("Zero-based index when multiple elements match (default: 0)."),
696
- x: z.coerce
697
- .number()
698
- .optional()
699
- .describe("X coordinate in pixels (from screenshot). Must provide both x and y."),
700
- y: z.coerce
701
- .number()
702
- .optional()
703
- .describe("Y coordinate in pixels (from screenshot). Must provide both x and y."),
704
- strategy: z
705
- .enum(["auto", "fiber", "accessibility", "ocr", "coordinate"])
706
- .optional()
707
- .default("auto")
708
- .describe('"auto" (default) tries fiber -> accessibility -> OCR. Set explicitly to skip strategies you know will fail.'),
709
- maxTraversalDepth: z.coerce
710
- .number()
711
- .optional()
712
- .describe("Max parent levels to traverse when searching by component name (default: 15). " +
713
- "Increase if your component is deeply wrapped (e.g. inside multiple HOCs/animation wrappers)."),
714
- native: z
715
- .boolean()
716
- .optional()
717
- .default(false)
718
- .describe("When true, tap coordinates directly via ADB/simctl without requiring a React Native connection. " +
719
- "Useful for interacting with native UI, system dialogs, or non-RN apps. Requires x/y coordinates."),
720
- platform: z
721
- .enum(["ios", "android"])
722
- .optional()
723
- .describe("Target platform. Required when both iOS and Android devices are connected. Auto-detected if only one platform is available."),
724
- device: z
725
- .string()
726
- .optional()
727
- .describe("Target device name (substring match against the connected RN app's device name). " +
728
- "Use to pin the tap to a specific device when multiple are connected (e.g. \"iPhone SE\"). " +
729
- "Run get_apps to see connected device names. For iOS, the matched device's simulatorUdid is used to scope the tap."),
730
- udid: z
731
- .string()
732
- .optional()
733
- .describe("iOS simulator UDID (from list_ios_simulators). Takes precedence over device/platform when set. " +
734
- "iOS-only — pairing with platform=\"android\" returns an error."),
735
- screenshot: z
736
- .boolean()
737
- .optional()
738
- .default(true)
739
- .describe("Return post-tap image bytes in the response. Default true. Set to false to drop the PNG bytes — verification still runs (set verify=false to skip that too). Combine with verify=true to get the meaningful/changeRate signal without paying the ~1MB-per-tap bandwidth cost."),
740
- verify: z
741
- .boolean()
742
- .optional()
743
- .describe("Run before/after screenshot diff to detect if the tap had a meaningful visual effect. " +
744
- "Default: true for coordinate/accessibility/ocr strategies, false for fiber. " +
745
- "Independent of `screenshot` — verify can run with screenshot=false (the diff is computed internally; image bytes are dropped). " +
746
- "When skipped, the response contains `verification: { skipped: true, skippedReason }` so callers can tell apart \"ran clean\" from \"never ran\"."),
747
- burst: z
748
- .boolean()
749
- .optional()
750
- .default(false)
751
- .describe("Enable burst screenshot capture for enhanced verification. " +
752
- "Captures 4 rapid screenshots (~150ms intervals) after the tap to detect transient visual feedback " +
753
- "(press animations, highlights, ripples) that may settle before a standard after-screenshot. " +
754
- "Results are stored in the image buffer (use get_images to inspect individual frames). " +
755
- "Default: false."),
756
- },
757
- }, async (args) => {
758
- const result = await tap({
759
- text: args.text,
760
- testID: args.testID,
761
- component: args.component,
762
- index: args.index,
763
- x: args.x,
764
- y: args.y,
765
- strategy: args.strategy,
766
- maxTraversalDepth: args.maxTraversalDepth,
767
- native: args.native,
768
- platform: args.platform,
769
- device: args.device,
770
- udid: args.udid,
771
- screenshot: args.screenshot,
772
- verify: args.verify,
773
- burst: args.burst,
774
- });
775
- const { screenshot: screenshotData, ...resultWithoutScreenshot } = result;
776
- const text = JSON.stringify(resultWithoutScreenshot, null, 2);
777
- // Pack predicate + strategy mode + attempted strategies into errorContext for telemetry.
778
- // Always include the predicate so unmeaningful outcomes (no isError, no _errorMessage) still
779
- // carry triage context — otherwise blob8 ends up blank and the dashboard shows empty rows.
780
- // e.g. "p={\"text\":\"Save\"}|s=ocr|fiber:no_pressable|ocr:no_match"
781
- const stratPrefix = args.strategy && args.strategy !== "auto" ? `s=${args.strategy}|` : "";
782
- let predicatePrefix = "";
783
- try {
784
- if (result.query !== undefined) {
785
- predicatePrefix = `p=${JSON.stringify(result.query)}|`;
786
- }
787
- }
788
- catch {
789
- // query may contain non-serializable values — drop the prefix rather than fail.
790
- }
791
- const attemptedPart = result.attempted?.length
792
- ? result.attempted.map(a => `${a.strategy}:${a.reason.slice(0, 40)}`).join("|")
793
- : "";
794
- const ctxParts = `${predicatePrefix}${stratPrefix}${attemptedPart}`;
795
- const errorContext = ctxParts ? ctxParts.replace(/\|$/, "") : undefined;
796
- const content = [
797
- { type: "text", text },
798
- ];
799
- if (screenshotData) {
800
- content.push({
801
- type: "image",
802
- data: screenshotData.image,
803
- mimeType: "image/jpeg",
804
- });
805
- }
806
- return {
807
- content,
808
- isError: !result.success && !result.ambiguous,
809
- _errorMessage: !result.success && !result.ambiguous
810
- ? `${JSON.stringify(result.query)}|${result.error || ""}`
811
- : undefined,
812
- _errorContext: errorContext,
813
- _meaningful: result.verification?.meaningful,
814
- _changeRate: result.verification?.changeRate,
815
- _tapStrategy: result.method,
816
- _iosDriver: result.platform === "ios" ? (process.env.IOS_DRIVER?.toLowerCase() || "axe") : undefined,
817
- _artifactKey: result.artifactKey,
818
- _ocrClosestMatch: result.ocrClosestMatch,
819
- _fiberPressableCount: result.fiberPressableCount,
820
- _accessibilityMatchCount: result.accessibilityMatchCount,
821
- _appRoute: result.appRoute,
822
- };
823
- });
824
- // Tool: Get full screen layout (all components with layout styles)
825
- registerToolWithTelemetry("get_screen_layout", {
826
- 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.)." +
827
- primaryInteractionBanner() + "\n" +
828
- "PURPOSE: Quickest textual map of what is actually on screen right now — component names, positions, and text — so you can plan taps and inspections without guessing.\n" +
829
- "WHEN TO USE: First step whenever the user asks \"what's on screen\", \"why is X covering Y\", or before tapping a visually ambiguous element.\n" +
830
- "WORKFLOW: get_screen_layout -> find_components(pattern=\"...\") or inspect_component(componentName=\"...\") -> tap(testID=...) -> get_screen_layout again to confirm.\n" +
831
- "LIMITATIONS: Coordinates are points/dp, not screenshot pixels — pass them to tap() which handles conversion, do not multiply by devicePixelRatio yourself.\n" +
832
- "GOOD: get_screen_layout({ extended: true })\n" +
833
- "BAD: get_screen_layout({ summary: true }) when you actually need to pick a specific element — summary hides the tree.\n" +
834
- "SEE ALSO: call get_usage_guide(topic=\"layout\") for the full layout-check playbook.",
835
- inputSchema: {
836
- extended: z
837
- .boolean()
838
- .optional()
839
- .default(false)
840
- .describe("Include layout styles (padding, margin, flex, backgroundColor, borderRadius, etc.) for each component. Default: false for compact output."),
841
- summary: z
842
- .boolean()
843
- .optional()
844
- .default(false)
845
- .describe("Return only component counts by name instead of full tree (default: false)"),
846
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
847
- }
848
- }, async ({ extended, summary, device }) => {
849
- if (!hasMetro()) {
850
- const hint = await metroMissingHintIfAbsent("get_screen_layout");
851
- return {
852
- content: [{ type: "text", text: `Screen Layout unavailable.${hint}` }],
853
- isError: true
854
- };
855
- }
856
- const result = await getScreenLayout({ extended, summary, device });
857
- if (!result.success) {
858
- return {
859
- content: [
860
- {
861
- type: "text",
862
- text: `Error: ${result.error}`
863
- }
864
- ],
865
- isError: true
866
- };
867
- }
868
- return {
869
- content: [
870
- {
871
- type: "text",
872
- text: `Screen Layout:\n\n${result.result}`
873
- }
874
- ]
875
- };
876
- });
877
- // Tool: Get console logs
878
- registerToolWithTelemetry("get_logs", {
879
- description: "Retrieve console logs from connected React Native app. Tip: Use summary=true first for a quick overview (counts by level + last 5 messages), then fetch specific logs as needed.\n" +
880
- "PURPOSE: Pull captured console output (log/warn/error/info/debug) from the in-memory buffer for the connected app.\n" +
881
- "WHEN TO USE: Start of any log-driven investigation, verifying a code change picked up via Fast Refresh, or confirming a reported error actually fires.\n" +
882
- "WORKFLOW: scan_metro -> get_logs(summary=true) -> narrow with search_logs(text=\"...\") or get_logs(level=\"error\") -> clear_logs between reproductions.\n" +
883
- "LIMITATIONS: Circular buffer (~500 entries). Only captures logs emitted after the app connected; pre-connect logs are lost.\n" +
884
- "GOOD: get_logs({ summary: true }) then get_logs({ level: \"error\", maxLogs: 20 })\n" +
885
- "BAD: get_logs({ maxLogs: 500, verbose: true }) as a first call — floods context; start with summary=true.\n" +
886
- "SEE ALSO: call get_usage_guide(topic=\"logs\") for the full console-debugging playbook.",
887
- inputSchema: {
888
- maxLogs: z.coerce
889
- .number()
890
- .optional()
891
- .default(50)
892
- .describe("Maximum number of logs to return (default: 50)"),
893
- level: z
894
- .enum(["all", "log", "warn", "error", "info", "debug"])
895
- .optional()
896
- .default("all")
897
- .describe("Filter by log level (default: all)"),
898
- startFromText: z.string().optional().describe("Start from the first log line containing this text"),
899
- maxMessageLength: z.coerce
900
- .number()
901
- .optional()
902
- .default(500)
903
- .describe("Max characters per message (default: 500, set to 0 for unlimited). Tip: Use lower values for overview, higher when debugging specific data structures."),
904
- verbose: z
905
- .boolean()
906
- .optional()
907
- .default(false)
908
- .describe("Disable all truncation and return full messages. Tip: Use with lower maxLogs (e.g., 10) to avoid token overload when inspecting large objects."),
909
- format: z
910
- .enum(["text", "tonl"])
911
- .optional()
912
- .default("tonl")
913
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format, ~30-50% smaller)"),
914
- summary: z
915
- .boolean()
916
- .optional()
917
- .default(false)
918
- .describe("Return summary statistics instead of full logs (count by level + last 5 messages). Use for quick overview."),
919
- device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
920
- }
921
- }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, format, summary, device }) => {
922
- // Check if SDK is installed — prefer SDK data for richer logs
923
- const sdkAvailable = await isSDKInstalled();
924
- if (sdkAvailable) {
925
- if (summary) {
926
- const sdkStats = await getSDKConsoleStats();
927
- if (sdkStats.success) {
928
- const s = sdkStats.data;
929
- const lines = [];
930
- lines.push(`Total logs: ${s.total}`);
931
- if (s.byLevel && Object.keys(s.byLevel).length > 0) {
932
- lines.push("\nBy Level:");
933
- for (const [lvl, cnt] of Object.entries(s.byLevel))
934
- lines.push(` ${lvl}: ${cnt}`);
935
- }
936
- return { content: [{ type: "text", text: `Log Summary (SDK):\n\n${lines.join("\n")}` }] };
937
- }
938
- }
939
- const sdkResult = await querySDKConsole({ count: maxLogs, level, text: startFromText });
940
- if (sdkResult.success) {
941
- const entries = sdkResult.data;
942
- if (entries.length === 0) {
943
- return { content: [{ type: "text", text: "No console logs captured yet." }] };
944
- }
945
- if (format === "tonl") {
946
- const tonlLines = entries.map((e) => {
947
- const time = new Date(e.timestamp).toLocaleTimeString();
948
- let msg = e.message;
949
- if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
950
- msg = msg.slice(0, maxMessageLength) + "...";
951
- }
952
- return `${time} [${e.level}] ${msg}`;
953
- });
954
- return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${tonlLines.join("\n")}` }] };
955
- }
956
- const lines = entries.map((e) => {
957
- const time = new Date(e.timestamp).toLocaleTimeString();
958
- let msg = e.message;
959
- if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
960
- msg = msg.slice(0, maxMessageLength) + "...";
961
- }
962
- return `[${time}] [${e.level.toUpperCase()}] ${msg}`;
963
- });
964
- return { content: [{ type: "text", text: `Console Logs (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
965
- }
966
- }
967
- // Return summary if requested
968
- if (summary) {
969
- const summaryText = getLogSummary(resolveLogBuffer(device), { lastN: 5, maxMessageLength: 100 });
970
- let connectionWarning = "";
971
- if (getTotalLogCount() === 0) {
972
- const status = await checkAndEnsureConnection(device);
973
- connectionWarning = status.message ? `\n\n${status.message}` : "";
974
- if (status.connected) {
975
- const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
976
- if (targetApp) {
977
- const pipeline = await verifyLogPipeline(targetApp);
978
- if (pipeline.message) {
979
- connectionWarning += `\n\n${pipeline.message}`;
980
- }
981
- }
982
- }
983
- connectionWarning += await metroMissingHintIfAbsent("get_logs");
984
- }
985
- return {
986
- content: [
987
- {
988
- type: "text",
989
- text: `Log Summary:\n\n${summaryText}${connectionWarning}`
990
- }
991
- ]
992
- };
993
- }
994
- const { logs, count, formatted } = getLogs(resolveLogBuffer(device), {
995
- maxLogs,
996
- level,
997
- startFromText,
998
- maxMessageLength,
999
- verbose
1000
- });
1001
- // Check connection health
1002
- let connectionWarning = "";
1003
- let emptyReason;
1004
- if (count === 0) {
1005
- const status = await checkAndEnsureConnection(device);
1006
- connectionWarning = status.message ? `\n\n${status.message}` : "";
1007
- // Track empty reason for telemetry
1008
- emptyReason = "no_logs";
1009
- if (!status.connected) {
1010
- emptyReason = "disconnected";
1011
- }
1012
- else if (status.wasReconnected) {
1013
- emptyReason = "post_reconnect";
1014
- }
1015
- // End-to-end log pipeline verification (with automatic recovery)
1016
- if (status.connected) {
1017
- const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
1018
- if (targetApp) {
1019
- const pipeline = await verifyLogPipeline(targetApp);
1020
- if (pipeline.message) {
1021
- connectionWarning += `\n\n${pipeline.message}`;
1022
- }
1023
- if (!pipeline.ok) {
1024
- emptyReason = "pipeline_failed";
1025
- }
1026
- else if (pipeline.recovered) {
1027
- emptyReason = "pipeline_recovered";
1028
- }
1029
- // If pipeline recovered, re-read the buffer — new logs may have arrived
1030
- if (pipeline.recovered && pipeline.ok) {
1031
- const retryResult = getLogs(resolveLogBuffer(device), {
1032
- maxLogs, level, startFromText, maxMessageLength, verbose
1033
- });
1034
- if (retryResult.count > 0) {
1035
- // Return the recovered logs instead of empty
1036
- return {
1037
- _emptyReason: "pipeline_recovered",
1038
- content: [{
1039
- type: "text",
1040
- text: `React Native Console Logs (${retryResult.count} entries):\n\n${retryResult.formatted}${connectionWarning}`
1041
- }]
1042
- };
1043
- }
1044
- }
1045
- }
1046
- }
1047
- // Add diagnostic metadata for empty results (captured by telemetry via responsePreview)
1048
- if (count === 0) {
1049
- const diagParts = [
1050
- `empty_reason=${status?.wasReconnected ? "post_reconnect" : "no_logs"}`,
1051
- `connection=${getPassiveConnectionStatus().reason}`,
1052
- `device_count=${connectedApps.size}`,
1053
- `buffer_sizes=${JSON.stringify(Object.fromEntries([...logBuffers.entries()].map(([k, v]) => [k, v.size])))}`,
1054
- ];
1055
- connectionWarning += `\n\n[DIAG] ${diagParts.join(", ")}`;
1056
- }
1057
- connectionWarning += await metroMissingHintIfAbsent("get_logs");
1058
- }
1059
- else {
1060
- const passive = getPassiveConnectionStatus();
1061
- connectionWarning = !passive.connected
1062
- ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
1063
- : "";
1064
- }
1065
- // Check for recent connection gaps
1066
- const warningThresholdMs = 30000; // 30 seconds
1067
- const recentGaps = getRecentGaps(warningThresholdMs);
1068
- let gapWarning = "";
1069
- if (recentGaps.length > 0) {
1070
- const latestGap = recentGaps[recentGaps.length - 1];
1071
- const gapDuration = latestGap.durationMs || Date.now() - latestGap.disconnectedAt.getTime();
1072
- if (latestGap.reconnectedAt) {
1073
- const secAgo = Math.round((Date.now() - latestGap.reconnectedAt.getTime()) / 1000);
1074
- gapWarning = `\n\n[WARNING] Connection was restored ${secAgo}s ago. Some logs may have been missed during the ${formatDuration(gapDuration)} gap.`;
1075
- }
1076
- else {
1077
- gapWarning = `\n\n[WARNING] Connection is currently disconnected. Logs may be incomplete.`;
1078
- }
1079
- }
1080
- const startNote = startFromText ? ` (starting from "${startFromText}")` : "";
1081
- // Use TONL format if requested
1082
- if (format === "tonl") {
1083
- const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
1084
- return {
1085
- ...(emptyReason && { _emptyReason: emptyReason }),
1086
- content: [
1087
- {
1088
- type: "text",
1089
- text: `React Native Console Logs (${count} entries)${startNote}:\n\n${tonlOutput}${gapWarning}${connectionWarning}`
1090
- }
1091
- ]
1092
- };
1093
- }
1094
- return {
1095
- ...(emptyReason && { _emptyReason: emptyReason }),
1096
- content: [
1097
- {
1098
- type: "text",
1099
- text: `React Native Console Logs (${count} entries)${startNote}:\n\n${formatted}${gapWarning}${connectionWarning}`
1100
- }
1101
- ]
1102
- };
1103
- },
1104
- // Empty result detector: buffer has no entries at all
1105
- () => getTotalLogCount() === 0);
1106
- // Tool: Search logs
1107
- registerToolWithTelemetry("search_logs", {
1108
- description: "Search console logs for text (case-insensitive).\n" +
1109
- "PURPOSE: Find log lines matching a substring across the connected app's console buffer.\n" +
1110
- "WHEN TO USE: User reports a known error/warning, or wants to trace a specific event (e.g., \"redux\", \"auth failed\"). For unfocused exploration, prefer get_logs.\n" +
1111
- "WORKFLOW: scan_metro -> search_logs(text=\"...\") -> if empty, get_logs to verify buffer populated.\n" +
1112
- "LIMITATIONS: Only matches text captured AFTER the app connected; won't find pre-connect logs.\n" +
1113
- "GOOD: search_logs({ text: \"TypeError\" })\n" +
1114
- "BAD: search_logs({ text: \"\" }) (use get_logs for a raw dump)\n" +
1115
- "SEE ALSO: call get_usage_guide(topic=\"logs\") for the full console-debugging playbook.",
1116
- inputSchema: {
1117
- text: z.string().describe("Text to search for in log messages"),
1118
- maxResults: z.coerce
1119
- .number()
1120
- .optional()
1121
- .default(50)
1122
- .describe("Maximum number of results to return (default: 50)"),
1123
- maxMessageLength: z.coerce
1124
- .number()
1125
- .optional()
1126
- .default(500)
1127
- .describe("Max characters per message (default: 500, set to 0 for unlimited)"),
1128
- verbose: z.boolean().optional().default(false).describe("Disable all truncation and return full messages"),
1129
- format: z
1130
- .enum(["text", "tonl"])
1131
- .optional()
1132
- .default("tonl")
1133
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)"),
1134
- device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
1135
- }
1136
- }, async ({ text, maxResults, maxMessageLength, verbose, format, device }) => {
1137
- // Check if SDK is installed — prefer SDK data
1138
- const sdkAvailable = await isSDKInstalled();
1139
- if (sdkAvailable) {
1140
- const sdkResult = await querySDKConsole({ count: maxResults, text });
1141
- if (sdkResult.success) {
1142
- const entries = sdkResult.data;
1143
- if (entries.length === 0) {
1144
- return { content: [{ type: "text", text: `No logs matching "${text}" found.` }] };
1145
- }
1146
- if (format === "tonl") {
1147
- const tonlLines = entries.map((e) => {
1148
- const time = new Date(e.timestamp).toLocaleTimeString();
1149
- let msg = e.message;
1150
- if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
1151
- msg = msg.slice(0, maxMessageLength) + "...";
1152
- }
1153
- return `${time} [${e.level}] ${msg}`;
1154
- });
1155
- return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${tonlLines.join("\n")}` }] };
1156
- }
1157
- const lines = entries.map((e) => {
1158
- const time = new Date(e.timestamp).toLocaleTimeString();
1159
- let msg = e.message;
1160
- if (!verbose && maxMessageLength > 0 && msg.length > maxMessageLength) {
1161
- msg = msg.slice(0, maxMessageLength) + "...";
1162
- }
1163
- return `[${time}] [${e.level.toUpperCase()}] ${msg}`;
1164
- });
1165
- return { content: [{ type: "text", text: `Search results for "${text}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
1166
- }
1167
- }
1168
- const { logs, count, formatted } = searchLogs(resolveLogBuffer(device), text, { maxResults, maxMessageLength, verbose });
1169
- // Check connection health
1170
- let connectionWarning = "";
1171
- if (count === 0) {
1172
- const status = await checkAndEnsureConnection(device);
1173
- connectionWarning = status.message ? `\n\n${status.message}` : "";
1174
- connectionWarning += await metroMissingHintIfAbsent("search_logs");
1175
- }
1176
- else {
1177
- const passive = getPassiveConnectionStatus();
1178
- connectionWarning = !passive.connected
1179
- ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
1180
- : "";
1181
- }
1182
- // Use TONL format if requested
1183
- if (format === "tonl") {
1184
- const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
1185
- return {
1186
- content: [
1187
- {
1188
- type: "text",
1189
- text: `Search results for "${text}" (${count} matches):\n\n${tonlOutput}${connectionWarning}`
1190
- }
1191
- ]
1192
- };
1193
- }
1194
- return {
1195
- content: [
1196
- {
1197
- type: "text",
1198
- text: `Search results for "${text}" (${count} matches):\n\n${formatted}${connectionWarning}`
1199
- }
1200
- ]
1201
- };
1202
- });
1203
- // Tool: iOS screenshot
1204
- registerToolWithTelemetry("ios_screenshot", {
1205
- 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.\n" +
1206
- "PURPOSE: Snapshot what the user sees on iOS AND get tap-ready pressables + a structured component map in one call.\n" +
1207
- "WHEN TO USE: Any visual verification, before/after comparison, or as the starting point for tapping UI by coordinates.\n" +
1208
- "WORKFLOW: ios_screenshot -> pick element from pressables -> tap(x, y) or tap(testID=...) -> ios_screenshot to verify.\n" +
1209
- "LIMITATIONS: Requires a booted iOS simulator (simctl). For physical devices or system dialogs without RN, combine with tap(..., native=true).\n" +
1210
- "GOOD: ios_screenshot()\n" +
1211
- "BAD: ios_screenshot({ udid: \"guess\" }) with a made-up UDID — run list_ios_simulators first.\n" +
1212
- "SEE ALSO: call get_usage_guide(topic=\"interact\") for the full device-interaction playbook.",
1213
- inputSchema: {
1214
- outputPath: z
1215
- .string()
1216
- .optional()
1217
- .describe("Optional path to save the screenshot. If not provided, saves to temp directory."),
1218
- udid: z
1219
- .string()
1220
- .optional()
1221
- .describe("Optional simulator UDID (from list_ios_simulators). Uses booted simulator if not specified.")
1222
- }
1223
- }, async ({ outputPath, udid }) => {
1224
- const result = await iosScreenshot(outputPath, udid);
1225
- if (!result.success) {
1226
- return {
1227
- content: [
1228
- {
1229
- type: "text",
1230
- text: `Error: ${result.error}`
1231
- }
1232
- ],
1233
- isError: true
1234
- };
1235
- }
1236
- // Include image data if available
1237
- if (result.data) {
1238
- // Build info text with coordinate guidance for iOS
1239
- const pixelWidth = result.originalWidth || 0;
1240
- const pixelHeight = result.originalHeight || 0;
1241
- // Resolve the RN app running on THIS simulator so enrichment pulls
1242
- // fiber/layout data from the right app (not the first-connected one,
1243
- // which may belong to a different simulator).
1244
- const resolvedUdid = udid || (await getActiveOrBootedSimulatorUdid());
1245
- const targetApp = resolvedUdid ? getConnectedAppBySimulatorUdid(resolvedUdid) : null;
1246
- const targetDeviceName = targetApp?.deviceInfo.deviceName;
1247
- // Store screenshot metadata on the matching app (not an arbitrary one)
1248
- if (targetApp) {
1249
- targetApp.lastScreenshot = {
1250
- originalWidth: pixelWidth,
1251
- originalHeight: pixelHeight,
1252
- scaleFactor: result.scaleFactor || 1,
1253
- };
1254
- }
1255
- // Try to get actual screen dimensions and safe area from accessibility tree
1256
- let pointWidth = 0;
1257
- let pointHeight = 0;
1258
- let scaleFactor = 3; // Default to 3x for modern iPhones
1259
- let safeAreaTop = 59; // Default safe area offset
1260
- try {
1261
- const describeResult = await iosDescribeAll(udid);
1262
- if (describeResult.success && describeResult.elements && describeResult.elements.length > 0) {
1263
- // First element is typically the Application with full screen frame
1264
- const rootElement = describeResult.elements[0];
1265
- // Try parsed frame first, then parse AXFrame string
1266
- if (rootElement.frame) {
1267
- pointWidth = Math.round(rootElement.frame.width);
1268
- pointHeight = Math.round(rootElement.frame.height);
1269
- // The frame.y of the root element indicates where content starts (after status bar)
1270
- if (rootElement.frame.y > 0) {
1271
- safeAreaTop = Math.round(rootElement.frame.y);
1272
- }
1273
- }
1274
- else if (rootElement.AXFrame) {
1275
- // Parse format: "{{x, y}, {width, height}}"
1276
- const match = rootElement.AXFrame.match(/\{\{([\d.]+),\s*([\d.]+)\},\s*\{([\d.]+),\s*([\d.]+)\}\}/);
1277
- if (match) {
1278
- const frameY = parseFloat(match[2]);
1279
- pointWidth = Math.round(parseFloat(match[3]));
1280
- pointHeight = Math.round(parseFloat(match[4]));
1281
- if (frameY > 0) {
1282
- safeAreaTop = Math.round(frameY);
1283
- }
1284
- }
1285
- }
1286
- // Calculate actual scale factor
1287
- if (pointWidth > 0) {
1288
- scaleFactor = Math.round(pixelWidth / pointWidth);
1289
- }
1290
- }
1291
- }
1292
- catch {
1293
- // Fallback: use 3x scale for modern devices
1294
- }
1295
- // Fallback if we couldn't get dimensions
1296
- if (pointWidth === 0) {
1297
- pointWidth = Math.round(pixelWidth / scaleFactor);
1298
- pointHeight = Math.round(pixelHeight / scaleFactor);
1299
- }
1300
- const safeAreaOffsetPixels = safeAreaTop * scaleFactor;
1301
- // The Screen Layout tree was previously appended here but produced huge noisy
1302
- // output (nested Svg/G/Path duplicates). Agents should use get_screen_layout
1303
- // explicitly when they need the tree. The Pressable elements block below is
1304
- // the signal most consumers actually want.
1305
- let pressablesText = null;
1306
- // Enrich with pressable elements. With targetApp present we use the
1307
- // fiber path (most accurate). Without targetApp, getPressableElements
1308
- // falls back to iOS accessibility tree (E1) when we pass platform.
1309
- try {
1310
- const pressables = await getPressableElements({
1311
- device: targetDeviceName,
1312
- platform: "ios",
1313
- udid: resolvedUdid ?? undefined
1314
- });
1315
- if (pressables.success && pressables.parsedElements && pressables.parsedElements.length > 0) {
1316
- const screenshotScale = result.scaleFactor || 1;
1317
- pressablesText = pressables.parsedElements.map((el) => {
1318
- // See enrichScreenshotWithLayout: shift y when fiber reports the element
1319
- // inside the safe-area band (a react-native-screens modal artifact).
1320
- let centerYPoints = el.center.y;
1321
- if (safeAreaTop > 0 && centerYPoints < safeAreaTop) {
1322
- centerYPoints += safeAreaTop;
1323
- }
1324
- const px = Math.round((el.center.x * scaleFactor) / screenshotScale);
1325
- const py = Math.round((centerYPoints * scaleFactor) / screenshotScale);
1326
- const label = el.accessibilityLabel || el.text || el.testID || (el.intent ? `${el.intent} icon` : el.component);
1327
- const idPart = el.testID ? ` testID="${el.testID}"` : "";
1328
- const kindPart = el.isInput ? " [input]" : "";
1329
- const wrapPart = el.isWrapper ? " [wrapper — skip]" : "";
1330
- const nearPart = !el.text && !el.accessibilityLabel && el.nearbyText ? ` near "${el.nearbyText}"` : "";
1331
- return ` (${px}, ${py}) ${el.component}: "${label}"${nearPart}${idPart}${kindPart}${wrapPart}`;
1332
- }).join("\n");
1333
- }
1334
- }
1335
- catch {
1336
- // Non-fatal: screenshot works without pressables enrichment
1337
- }
1338
- let infoText = `Screenshot captured (${pixelWidth}x${pixelHeight} pixels)`;
1339
- infoText += `\n📱 iOS screen: ${pointWidth}x${pointHeight} points (${scaleFactor}x scale)`;
1340
- infoText += `\n📐 tap() handles pixel-to-point conversion automatically — pass pixel coords from this image directly`;
1341
- infoText += `\n⚠️ Status bar + safe area: ${safeAreaTop} points (${safeAreaOffsetPixels} pixels) from top`;
1342
- if (result.scaleFactor && result.scaleFactor > 1) {
1343
- infoText += `\n🖼️ Image was scaled down to fit API limits (scale: ${result.scaleFactor.toFixed(3)})`;
1344
- }
1345
- if (pressablesText) {
1346
- infoText += `\n\n🎯 Pressable elements (ready-to-tap, coordinates in screenshot pixels):`;
1347
- infoText += `\n${pressablesText}`;
1348
- infoText += `\n\n💡 Next steps:`;
1349
- infoText += `\n • tap(text="Button Label") — when text is exact and unique`;
1350
- infoText += `\n • tap(testID="id") or tap(component="Name") — when you know the identifier`;
1351
- infoText += `\n • tap(x=<px>, y=<px>) — use coordinates from the pressable elements list above (reliable for icons and ambiguous elements)`;
1352
- infoText += `\n • get_screen_layout — full component tree when you need more than pressables`;
1353
- }
1354
- else {
1355
- if (!targetApp && connectedApps.size > 0) {
1356
- infoText += `\n\nℹ️ Pressable enrichment skipped: no RN app is connected to simulator ${resolvedUdid}.`;
1357
- infoText += ` ${connectedApps.size} other app(s) are connected on different device(s) — their fiber data was intentionally not used to avoid mismatched output.`;
1358
- }
1359
- infoText += `\n\n💡 Next steps:`;
1360
- infoText += `\n • tap(text="Button Label") — tap element by visible text`;
1361
- infoText += `\n • tap(x=<px>, y=<px>) — tap at coordinates from this screenshot`;
1362
- infoText += `\n • get_screen_layout — get full UI tree with real on-screen positions`;
1363
- }
1364
- // Check for LogBox overlay — only on the matching RN app; skip otherwise
1365
- // to avoid surfacing warnings from a different simulator's app.
1366
- if (targetApp) {
1367
- try {
1368
- const logBoxState = await detectLogBox(targetDeviceName);
1369
- if (logBoxState && logBoxState.total > 0) {
1370
- infoText += formatLogBoxWarning(logBoxState);
1371
- }
1372
- }
1373
- catch {
1374
- // Non-fatal: LogBox detection failure should not break screenshot
1375
- }
1376
- }
1377
- // I1 (2026-05-16): detect native iOS system overlays (auth sheets, alerts,
1378
- // permission dialogs) that sit on top of the RN app. The pressables list
1379
- // reflects the RN screen underneath; without this warning the agent will
1380
- // happily tap inert RN buttons and loop. Runs whether or not there's a
1381
- // matching RN app — the overlay belongs to the simulator, not the app.
1382
- try {
1383
- const overlay = await detectIOSSystemOverlay(resolvedUdid ?? undefined);
1384
- if (overlay) {
1385
- infoText += formatIOSSystemOverlayWarning(overlay);
1386
- }
1387
- }
1388
- catch {
1389
- // Non-fatal: overlay detection failure should not break screenshot
1390
- }
1391
- imageBuffer.add({
1392
- id: `ios-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1393
- image: result.data,
1394
- timestamp: Date.now(),
1395
- source: "ios_screenshot",
1396
- metadata: {
1397
- width: result.originalWidth || 0,
1398
- height: result.originalHeight || 0,
1399
- scaleFactor: result.scaleFactor || 1,
1400
- platform: "ios",
1401
- },
1402
- });
1403
- return {
1404
- content: [
1405
- {
1406
- type: "text",
1407
- text: infoText
1408
- },
1409
- {
1410
- type: "image",
1411
- data: result.data.toString("base64"),
1412
- mimeType: "image/jpeg"
1413
- }
1414
- ]
1415
- };
1416
- }
1417
- return {
1418
- content: [
1419
- {
1420
- type: "text",
1421
- text: `Screenshot saved to: ${result.result}`
1422
- }
1423
- ]
1424
- };
1425
- });
1426
- // Tool: Android screenshot
1427
- registerToolWithTelemetry("android_screenshot", {
1428
- 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.\n" +
1429
- "PURPOSE: Snapshot what the user sees on Android AND get tap-ready pressables + a structured component map in one call.\n" +
1430
- "WHEN TO USE: Any visual verification, before/after comparison, or as the starting point for tapping UI by coordinates on Android.\n" +
1431
- "WORKFLOW: android_screenshot -> pick element from pressables -> tap(x, y) or tap(testID=...) -> android_screenshot to verify.\n" +
1432
- "LIMITATIONS: Requires adb in PATH and a running device/emulator. For non-RN surfaces (system dialogs, permission prompts), combine with tap(..., native=true).\n" +
1433
- "GOOD: android_screenshot()\n" +
1434
- "BAD: android_screenshot({ deviceId: \"guess\" }) with a made-up serial — run list_android_devices first.\n" +
1435
- "SEE ALSO: call get_usage_guide(topic=\"interact\") for the full device-interaction playbook.",
1436
- inputSchema: {
1437
- outputPath: z
1438
- .string()
1439
- .optional()
1440
- .describe("Optional path to save the screenshot. If not provided, saves to temp directory."),
1441
- deviceId: z
1442
- .string()
1443
- .optional()
1444
- .describe("Optional device ID (from list_android_devices). Uses first available device if not specified.")
1445
- }
1446
- }, async ({ outputPath, deviceId }) => {
1447
- const result = await androidScreenshot(outputPath, deviceId);
1448
- if (!result.success) {
1449
- return {
1450
- content: [
1451
- {
1452
- type: "text",
1453
- text: `Error: ${result.error}`
1454
- }
1455
- ],
1456
- isError: true
1457
- };
1458
- }
1459
- // Include image data if available
1460
- if (result.data) {
1461
- // Build info text with coordinate conversion guidance
1462
- const pixelWidth = result.originalWidth || 0;
1463
- const pixelHeight = result.originalHeight || 0;
1464
- // Resolve the RN app running on THIS Android device so enrichment
1465
- // pulls data from the right app (not whichever app is "first").
1466
- const targetApp = getConnectedAppByAndroidDeviceId(deviceId);
1467
- const targetDeviceName = targetApp?.deviceInfo.deviceName;
1468
- // Store screenshot metadata on the matching app (not an arbitrary one)
1469
- if (targetApp) {
1470
- targetApp.lastScreenshot = {
1471
- originalWidth: pixelWidth,
1472
- originalHeight: pixelHeight,
1473
- scaleFactor: result.scaleFactor || 1,
1474
- };
1475
- }
1476
- let infoText = `Screenshot captured (${pixelWidth}x${pixelHeight} pixels)`;
1477
- // Get status bar height for coordinate guidance
1478
- let statusBarPixels = 63; // Default fallback
1479
- let statusBarDp = 24;
1480
- let densityDpi = 440; // Common default
1481
- try {
1482
- const statusBarResult = await androidGetStatusBarHeight(deviceId);
1483
- if (statusBarResult.success && statusBarResult.heightPixels) {
1484
- statusBarPixels = statusBarResult.heightPixels;
1485
- statusBarDp = statusBarResult.heightDp || 24;
1486
- }
1487
- const densityResult = await androidGetDensity(deviceId);
1488
- if (densityResult.success && densityResult.density) {
1489
- densityDpi = densityResult.density;
1490
- }
1491
- }
1492
- catch {
1493
- // Use defaults
1494
- }
1495
- // Enrich with screen layout data (component names + tap coordinates).
1496
- // On Bridgeless/Fabric Android (the only target architecture we support;
1497
- // legacy arch is <5% of users and not a priority), both code paths that
1498
- // feed this enrichment return DEVICE PIXELS:
1499
- // - fiber path: React's measureInWindow on Fabric returns native pixels.
1500
- // - a11y fallback: uiautomator's bounds are already device pixels.
1501
- // The earlier formula multiplied by densityDpi/160 on the (incorrect)
1502
- // assumption that fiber returned DP — inflating every coordinate by
1503
- // ~2.6× on a 420dpi emulator and producing numbers like (1170, 4054)
1504
- // for a button visually sitting near (445, 1370) in the JPEG. Drop the
1505
- // density factor; only the scaleFactor downscale is needed.
1506
- let pressablesText = null;
1507
- // Screen Layout tree previously appended here was dropped — it was noisy
1508
- // (nested Svg/G/Path duplicates). Use get_screen_layout when the tree is needed.
1509
- // Enrich with pressable elements. With targetApp present we use the
1510
- // fiber path (most accurate). Without targetApp, getPressableElements
1511
- // falls back to the Android accessibility (uiautomator) tree (E1).
1512
- try {
1513
- const pressables = await getPressableElements({ device: targetDeviceName, platform: "android" });
1514
- if (pressables.success && pressables.parsedElements && pressables.parsedElements.length > 0) {
1515
- const screenshotScale = result.scaleFactor || 1;
1516
- pressablesText = pressables.parsedElements.map((el) => {
1517
- const px = Math.round(el.center.x / screenshotScale);
1518
- const py = Math.round(el.center.y / screenshotScale);
1519
- const label = el.accessibilityLabel || el.text || el.testID || (el.intent ? `${el.intent} icon` : el.component);
1520
- const idPart = el.testID ? ` testID="${el.testID}"` : "";
1521
- const kindPart = el.isInput ? " [input]" : "";
1522
- const wrapPart = el.isWrapper ? " [wrapper — skip]" : "";
1523
- const nearPart = !el.text && !el.accessibilityLabel && el.nearbyText ? ` near "${el.nearbyText}"` : "";
1524
- return ` (${px}, ${py}) ${el.component}: "${label}"${nearPart}${idPart}${kindPart}${wrapPart}`;
1525
- }).join("\n");
1526
- }
1527
- }
1528
- catch {
1529
- // Non-fatal: screenshot works without pressables enrichment
1530
- }
1531
- infoText += `\n📱 Android uses PIXELS for all coordinates`;
1532
- if (result.scaleFactor && result.scaleFactor > 1) {
1533
- infoText += `\n🖼️ Image was scaled down to fit API limits (scale: ${result.scaleFactor.toFixed(3)})`;
1534
- infoText += `\n📐 tap() handles coordinate conversion automatically — pass pixel coords from this image directly`;
1535
- }
1536
- else {
1537
- infoText += `\n📐 Screenshot coords = tap coords (no conversion needed)`;
1538
- }
1539
- infoText += `\n⚠️ Status bar: ${statusBarPixels}px (${statusBarDp}dp) from top - app content starts below this`;
1540
- infoText += `\n📊 Display density: ${densityDpi}dpi`;
1541
- if (pressablesText) {
1542
- infoText += `\n\n🎯 Pressable elements (ready-to-tap, coordinates in screenshot pixels):`;
1543
- infoText += `\n${pressablesText}`;
1544
- infoText += `\n\n💡 Next steps:`;
1545
- infoText += `\n • tap(text="Button Label") — when text is exact and unique`;
1546
- infoText += `\n • tap(testID="id") or tap(component="Name") — when you know the identifier`;
1547
- infoText += `\n • tap(x=<px>, y=<px>) — use coordinates from the pressable elements list above (reliable for icons and ambiguous elements)`;
1548
- infoText += `\n • get_screen_layout — full component tree when you need more than pressables`;
1549
- }
1550
- else {
1551
- if (!targetApp && connectedApps.size > 0) {
1552
- infoText += `\n\nℹ️ Pressable enrichment skipped: no RN app is connected to device ${deviceId ?? "(default)"}.`;
1553
- infoText += ` ${connectedApps.size} other app(s) are connected on different device(s) — their fiber data was intentionally not used to avoid mismatched output.`;
1554
- }
1555
- infoText += `\n\n💡 Next steps:`;
1556
- infoText += `\n • tap(text="Button Label") — tap element by visible text`;
1557
- infoText += `\n • tap(x=<px>, y=<px>) — tap at coordinates from this screenshot`;
1558
- infoText += `\n • get_screen_layout — get full UI tree with real on-screen positions`;
1559
- }
1560
- // Check for LogBox overlay — only on the matching RN app; skip otherwise
1561
- // to avoid surfacing warnings from a different device's app.
1562
- if (targetApp) {
1563
- try {
1564
- const logBoxState = await detectLogBox(targetDeviceName);
1565
- if (logBoxState && logBoxState.total > 0) {
1566
- infoText += formatLogBoxWarning(logBoxState);
1567
- }
1568
- }
1569
- catch {
1570
- // Non-fatal: LogBox detection failure should not break screenshot
1571
- }
1572
- }
1573
- imageBuffer.add({
1574
- id: `android-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1575
- image: result.data,
1576
- timestamp: Date.now(),
1577
- source: "android_screenshot",
1578
- metadata: {
1579
- width: result.originalWidth || 0,
1580
- height: result.originalHeight || 0,
1581
- scaleFactor: result.scaleFactor || 1,
1582
- platform: "android",
1583
- },
1584
- });
1585
- return {
1586
- content: [
1587
- {
1588
- type: "text",
1589
- text: infoText
1590
- },
1591
- {
1592
- type: "image",
1593
- data: result.data.toString("base64"),
1594
- mimeType: "image/jpeg"
1595
- }
1596
- ]
1597
- };
1598
- }
1599
- return {
1600
- content: [
1601
- {
1602
- type: "text",
1603
- text: `Screenshot saved to: ${result.result}`
1604
- }
1605
- ]
1606
- };
1607
- });
1608
- // Tool: Get connection status (detailed health and gap tracking)
1609
- registerToolWithTelemetry("get_connection_status", {
1610
- description: "Get detailed connection health status including uptime, recent disconnects/reconnects, and connection gaps that may indicate missing data.\n" +
1611
- "PURPOSE: Diagnose flaky CDP sessions — quantify uptime, count reconnects, and expose gaps where logs/network data could be missing.\n" +
1612
- "WHEN TO USE: When logs look suspiciously empty, tools complain about disconnect/reconnect, or the app was suspended and resumed.\n" +
1613
- "WORKFLOW: get_connection_status -> if unhealthy: disconnect_metro -> scan_metro to rebuild a clean session.\n" +
1614
- "LIMITATIONS: Reports only MCP-side view; doesn't know why Metro dropped the socket (simulator sleep, app backgrounded, etc).\n" +
1615
- "GOOD: get_connection_status() after noticing stale data.\n" +
1616
- "BAD: Polling get_connection_status as a heartbeat — use ensure_connection(healthCheck=true) for a live probe.\n" +
1617
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
1618
- inputSchema: {}
1619
- }, async () => {
1620
- const connections = getConnectedApps();
1621
- const states = getAllConnectionStates();
1622
- const metadata = getAllConnectionMetadata();
1623
- const lines = [];
1624
- lines.push("=== Connection Status ===\n");
1625
- if (connections.length === 0 && states.size === 0) {
1626
- lines.push("No connections established. Run scan_metro to connect.");
1627
- return {
1628
- content: [{ type: "text", text: lines.join("\n") }]
1629
- };
1630
- }
1631
- // Show active connections
1632
- for (const { key, app, isConnected } of connections) {
1633
- const state = states.get(key);
1634
- const contextHealth = getContextHealth(key);
1635
- lines.push(`--- ${app.deviceInfo.title} (Port ${app.port}) ---`);
1636
- lines.push(` Status: ${isConnected ? "CONNECTED" : "DISCONNECTED"}`);
1637
- if (state) {
1638
- if (state.lastConnectedTime) {
1639
- const uptime = Date.now() - state.lastConnectedTime.getTime();
1640
- lines.push(` Connected since: ${state.lastConnectedTime.toLocaleTimeString()}`);
1641
- lines.push(` Uptime: ${formatDuration(uptime)}`);
1642
- }
1643
- if (state.status === "reconnecting") {
1644
- lines.push(` Reconnecting: Attempt ${state.reconnectionAttempts}`);
1645
- }
1646
- // Show recent gaps (last 5 minutes)
1647
- if (state.connectionGaps.length > 0) {
1648
- const recentGaps = state.connectionGaps.filter((g) => Date.now() - g.disconnectedAt.getTime() < 300000);
1649
- if (recentGaps.length > 0) {
1650
- lines.push(` Recent gaps: ${recentGaps.length}`);
1651
- for (const gap of recentGaps.slice(-3)) {
1652
- const duration = gap.durationMs ? formatDuration(gap.durationMs) : "ongoing";
1653
- lines.push(` - ${gap.disconnectedAt.toLocaleTimeString()} (${duration}): ${gap.reason}`);
1654
- }
1655
- }
1656
- }
1657
- }
1658
- // Show context health
1659
- if (contextHealth) {
1660
- lines.push(` Context Health:`);
1661
- lines.push(` Context ID: ${contextHealth.contextId ?? "unknown"}`);
1662
- lines.push(` Status: ${contextHealth.isStale ? "STALE" : "HEALTHY"}`);
1663
- if (contextHealth.lastHealthCheck) {
1664
- const healthResult = contextHealth.lastHealthCheckSuccess ? "PASS" : "FAIL";
1665
- lines.push(` Last Check: ${contextHealth.lastHealthCheck.toLocaleTimeString()} (${healthResult})`);
1666
- }
1667
- if (contextHealth.lastContextCreated) {
1668
- lines.push(` Context Created: ${contextHealth.lastContextCreated.toLocaleTimeString()}`);
1669
- }
1670
- if (contextHealth.lastContextDestroyed) {
1671
- lines.push(` Context Destroyed: ${contextHealth.lastContextDestroyed.toLocaleTimeString()}`);
1672
- }
1673
- }
1674
- lines.push("");
1675
- }
1676
- // Show disconnected/reconnecting states without active connections
1677
- for (const [key, state] of states.entries()) {
1678
- if (!connections.find((c) => c.key === key)) {
1679
- const meta = metadata.get(key);
1680
- lines.push(`--- ${meta?.deviceInfo.title || key} (Disconnected) ---`);
1681
- lines.push(` Status: ${state.status.toUpperCase()}`);
1682
- if (state.lastDisconnectTime) {
1683
- lines.push(` Disconnected at: ${state.lastDisconnectTime.toLocaleTimeString()}`);
1684
- }
1685
- if (state.reconnectionAttempts > 0) {
1686
- lines.push(` Reconnection attempts: ${state.reconnectionAttempts}`);
1687
- }
1688
- lines.push("");
1689
- }
1690
- }
1691
- return {
1692
- content: [{ type: "text", text: lines.join("\n") }]
1693
- };
1694
- });
1695
- // Tool: License status
1696
- registerToolWithTelemetry("get_license_status", {
1697
- 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.",
1698
- inputSchema: {},
1699
- }, async () => {
1700
- const status = getLicenseStatus();
1701
- const lines = [];
1702
- lines.push(`Installation ID: ${status.installationId}`);
1703
- lines.push(`License: ${status.tier.charAt(0).toUpperCase() + status.tier.slice(1)}`);
1704
- if (status.plan) {
1705
- lines.push(`Plan expires: ${status.plan.expiresAt}`);
1706
- }
1707
- lines.push(`Cache valid until: ${status.cacheExpiresAt}`);
1708
- // Usage limits
1709
- const usage = getUsageInfo();
1710
- if (usage) {
1711
- lines.push("");
1712
- lines.push("--- Usage ---");
1713
- if (usage.creditsRemaining !== null) {
1714
- lines.push(`Credits remaining: ${usage.creditsRemaining.toLocaleString()}`);
1715
- lines.push(`Used this month: ${usage.used.toLocaleString()}`);
1716
- }
1717
- else {
1718
- lines.push(`Monthly usage: ${usage.used} / ${usage.limit}`);
1719
- }
1720
- lines.push(`Month: ${usage.monthKey}`);
1721
- if (usage.promotionalPeriod) {
1722
- const endsAt = usage.promotionalPeriodEndsAt
1723
- ? new Date(usage.promotionalPeriodEndsAt).toLocaleDateString("en-GB", { day: "2-digit", month: "2-digit", year: "numeric" })
1724
- : "unknown";
1725
- lines.push(`Status: Active (promotional period — no limits until ${endsAt})`);
1726
- }
1727
- else {
1728
- lines.push(`Status: ${usage.canUse ? "Active" : "Limit reached"}`);
1729
- }
1730
- }
1731
- if (status.tier === "free") {
1732
- const dashboardUrl = getDashboardUrl();
1733
- lines.push("");
1734
- if (dashboardUrl) {
1735
- lines.push(`Link your account: ${dashboardUrl}/link?id=${status.installationId}`);
1736
- }
1737
- }
1738
- return {
1739
- content: [{ type: "text", text: lines.join("\n") }],
1740
- };
1741
- });
1742
- // Tool: Send feedback / bug report / feature request
1743
- registerToolWithTelemetry("send_feedback", {
1744
- description: "Create a feedback report (bug, feature request, or general feedback) for the ExecBro team. " +
1745
- "Auto-collects environment info. Returns a pre-filled GitHub issue URL and formatted issue body. " +
1746
- "Ask the user to open the URL and paste the body to submit.",
1747
- inputSchema: {
1748
- type: z.enum(["feedback", "feature_request", "bug"]).describe('Type of feedback: "feedback", "feature_request", or "bug"'),
1749
- title: z.string().describe("Short summary (becomes the GitHub issue title)"),
1750
- description: z.string().describe("Detailed description of the feedback, feature request, or bug"),
1751
- workflow_context: z.string().optional().describe("What the user was trying to accomplish when they decided to give feedback")
1752
- }
1753
- }, async ({ type, title, description, workflow_context }) => {
1754
- // Collect environment info
1755
- const serverVersion = getServerVersion();
1756
- const platform = process.platform;
1757
- const deviceType = getTargetPlatform();
1758
- const licenseStatus = getLicenseStatus();
1759
- const env = {
1760
- serverVersion,
1761
- platform,
1762
- deviceType,
1763
- licenseTier: licenseStatus.tier
1764
- };
1765
- const input = { type, title, description, workflowContext: workflow_context };
1766
- const issueBody = formatIssueBody(input, env);
1767
- const githubUrl = buildGitHubUrl(title, type);
1768
- const output = [
1769
- "## Feedback Report Ready",
1770
- "",
1771
- `**GitHub Issue URL:** ${githubUrl}`,
1772
- "",
1773
- "**Issue body to paste:**",
1774
- "",
1775
- "```markdown",
1776
- issueBody,
1777
- "```",
1778
- "",
1779
- "Please ask the user to:",
1780
- "1. Open the GitHub URL above",
1781
- "2. Paste the issue body into the description field",
1782
- "3. Review and submit the issue"
1783
- ].join("\n");
1784
- return {
1785
- content: [{ type: "text", text: output }]
1786
- };
1787
- });
1788
- // Tool: Clear logs
1789
- registerToolWithTelemetry("clear_logs", {
1790
- description: "Clear the log buffer.\n" +
1791
- "PURPOSE: Empty the in-memory console buffer (and the SDK buffer if installed) so the next get_logs / search_logs only sees fresh entries.\n" +
1792
- "WHEN TO USE: Before reproducing a bug so the resulting logs are isolated; between test iterations to avoid noise from earlier runs.\n" +
1793
- "WORKFLOW: clear_logs -> reproduce the issue (tap / navigate / reload_app) -> get_logs or search_logs.\n" +
1794
- "GOOD: clear_logs() right before tap(text=\"Submit\")\n" +
1795
- "BAD: clear_logs() AFTER the repro — you just deleted the evidence.\n" +
1796
- "SEE ALSO: call get_usage_guide(topic=\"logs\") for the full console-debugging playbook.",
1797
- inputSchema: {
1798
- device: z.string().optional().describe("Target device name (substring match). Omit to clear all devices. Run get_apps to see connected devices.")
1799
- }
1800
- }, async ({ device }) => {
1801
- if (device) {
1802
- const app = getConnectedAppByDevice(device);
1803
- if (!app)
1804
- throw new UserInputError(`No connected device matches "${device}"`);
1805
- const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
1806
- const count = getLogBuffer(deviceName).clear();
1807
- return { content: [{ type: "text", text: `Cleared ${count} log entries from ${deviceName}.` }] };
1808
- }
1809
- // Clear all
1810
- let total = 0;
1811
- for (const buffer of logBuffers.values()) {
1812
- total += buffer.clear();
1813
- }
1814
- // Also clear SDK buffer if available
1815
- const sdkAvailable = await isSDKInstalled();
1816
- if (sdkAvailable) {
1817
- const sdkResult = await clearSDKConsole();
1818
- if (sdkResult.success && sdkResult.count) {
1819
- total += sdkResult.count;
1820
- }
1821
- }
1822
- return { content: [{ type: "text", text: `Cleared ${total} log entries from all devices.` }] };
1823
- });
1824
- // Tool: Connect to specific Metro port
1825
- registerToolWithTelemetry("connect_metro", {
1826
- description: "Connect to a Metro server on a specific port.\n" +
1827
- "[DEPRECATED IN PRACTICE — prefer `scan_metro` which auto-discovers all common ports and connects every Bridgeless target at once. Use `connect_metro` only when you need to target a specific non-default port]\n" +
1828
- "PURPOSE: Establish a CDP WebSocket connection to a single Metro server on a known, non-default port.\n" +
1829
- "WHEN TO USE: Only when the Metro bundler is running on a port outside the common 8081/8082/19000-19002 range that `scan_metro` already covers — otherwise always prefer `scan_metro`.\n" +
1830
- "SEE ALSO: call scan_metro for auto-discovery; call get_apps afterwards to confirm the device attached.",
1831
- inputSchema: {
1832
- port: z.coerce.number().default(8081).describe("Metro server port (default: 8081)")
1833
- }
1834
- }, async ({ port }) => {
1835
- try {
1836
- const devices = await fetchDevices(port);
1837
- if (devices.length === 0) {
1838
- return {
1839
- content: [
1840
- {
1841
- type: "text",
1842
- text: `No devices found on port ${port}. Make sure the app is running.`
1843
- }
1844
- ]
1845
- };
1846
- }
1847
- const results = [`Found ${devices.length} device(s) on port ${port}:`];
1848
- for (const device of devices) {
1849
- try {
1850
- const result = await connectToDevice(device, port);
1851
- results.push(` - ${result}`);
1852
- }
1853
- catch (error) {
1854
- results.push(` - ${device.title}: Failed - ${error}`);
1855
- }
1856
- }
1857
- // Also connect to Metro build events
1858
- try {
1859
- await connectMetroBuildEvents(port);
1860
- results.push(` - Connected to Metro build events`);
1861
- }
1862
- catch {
1863
- // Build events connection is optional
1864
- }
1865
- return {
1866
- content: [
1867
- {
1868
- type: "text",
1869
- text: results.join("\n")
1870
- }
1871
- ]
1872
- };
1873
- }
1874
- catch (error) {
1875
- return {
1876
- content: [
1877
- {
1878
- type: "text",
1879
- text: `Failed to connect: ${error}`
1880
- }
1881
- ]
1882
- };
1883
- }
1884
- });
1885
- // Tool: Disconnect from Metro
1886
- registerToolWithTelemetry("disconnect_metro", {
1887
- 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.\n" +
1888
- "PURPOSE: Cleanly release CDP slots so another debugger (Flipper, React DevTools, Chrome) can attach, or nuke a stale connection the MCP keeps reviving.\n" +
1889
- "WHEN TO USE: Before launching a native debugger, when connections keep flapping, or after a simulator/device restart left zombie targets.\n" +
1890
- "WORKFLOW: disconnect_metro -> attach other debugger / restart app -> scan_metro to reconnect.\n" +
1891
- "LIMITATIONS: Suppresses auto-reconnect until scan_metro or connect_metro is called again; buffers persist but won't receive new events.\n" +
1892
- "GOOD: disconnect_metro(); disconnect_metro({ device: \"iPhone\" })\n" +
1893
- "BAD: Using disconnect_metro to clear logs — use clear_logs instead; disconnect breaks capture.\n" +
1894
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
1895
- inputSchema: {
1896
- device: z.string().optional().describe("Target device name (substring match) to disconnect. Omit to disconnect all devices. Run get_apps to see connected devices.")
1897
- }
1898
- }, async ({ device }) => {
1899
- const connections = getConnectedApps();
1900
- if (connections.length === 0) {
1901
- return {
1902
- content: [
1903
- {
1904
- type: "text",
1905
- text: "No active Metro connections to disconnect."
1906
- }
1907
- ]
1908
- };
1909
- }
1910
- // Targeted disconnect: only disconnect a specific device
1911
- if (device) {
1912
- const app = getConnectedAppByDevice(device);
1913
- if (!app) {
1914
- return {
1915
- content: [{ type: "text", text: `No connected device matches "${device}". Run get_apps to see connected devices.` }],
1916
- isError: true
1917
- };
1918
- }
1919
- // Find and close the matching connection
1920
- for (const [key, connectedApp] of connectedApps.entries()) {
1921
- if (connectedApp.ws === app.ws) {
1922
- // Suppress reconnection for this specific device
1923
- suppressReconnectionForKey(key);
1924
- cancelReconnectionTimer(key);
1925
- try {
1926
- connectedApp.ws.close();
1927
- }
1928
- catch {
1929
- // Ignore close errors
1930
- }
1931
- connectedApps.delete(key);
1932
- const name = connectedApp.deviceInfo.deviceName || connectedApp.deviceInfo.title;
1933
- return {
1934
- content: [{ type: "text", text: `Disconnected from ${name} (port ${connectedApp.port}). Buffers preserved. Use scan_metro to reconnect.` }]
1935
- };
1936
- }
1937
- }
1938
- }
1939
- // Disconnect all
1940
- const disconnected = [];
1941
- // Suppress reconnection BEFORE closing sockets
1942
- // (close handlers fire async and would re-schedule reconnection)
1943
- suppressReconnection();
1944
- cancelAllReconnectionTimers();
1945
- // Close all CDP WebSocket connections
1946
- for (const [key, app] of connectedApps.entries()) {
1947
- try {
1948
- app.ws.close();
1949
- }
1950
- catch {
1951
- // Ignore close errors
1952
- }
1953
- disconnected.push(`${app.deviceInfo.title} (port ${app.port})`);
1954
- connectedApps.delete(key);
1955
- }
1956
- // Disconnect Metro build events WebSocket
1957
- disconnectMetroBuildEvents();
1958
- // Clear connection state (but NOT log/network buffers)
1959
- clearAllConnectionState();
1960
- clearAllCDPMessageTimes();
1961
- const lines = [
1962
- `Disconnected from ${disconnected.length} app(s):`,
1963
- ...disconnected.map((d) => ` - ${d}`),
1964
- "",
1965
- "Metro CDP connection is now free for the built-in React Native debugger.",
1966
- "Log and network buffers are preserved.",
1967
- 'Use "scan_metro" to reconnect when ready.'
1968
- ];
1969
- return {
1970
- content: [{ type: "text", text: lines.join("\n") }]
1971
- };
1972
- });
1973
- // Tool: Execute JavaScript in app
1974
- registerToolWithTelemetry("execute_in_app", {
1975
- description: "Execute JavaScript code in the connected React Native app and return the result. Use this for inspecting app state, calling methods on exposed global objects, or running diagnostic code. Hermes compatible: 'global' is automatically polyfilled to 'globalThis', so both global.__REDUX_STORE__ and globalThis.__REDUX_STORE__ work.\n\n" +
1976
- "RECOMMENDED WORKFLOW: 1) list_debug_globals to discover available objects, 2) inspect_global to see properties/methods, 3) execute_in_app to call specific methods or read values.\n\n" +
1977
- "LIMITATIONS (Hermes engine):\n" +
1978
- "- NO require() or import — only pre-existing globals are available\n" +
1979
- "- NO async/await syntax — use simple expressions or promise chains (.then())\n" +
1980
- "- NO emoji or non-ASCII characters in string literals — causes parse errors\n" +
1981
- "- Keep expressions simple and synchronous when possible\n\n" +
1982
- "GOOD examples: `__DEV__`, `__APOLLO_CLIENT__.cache.extract()`, `__EXPO_ROUTER__.navigate('/settings')`\n" +
1983
- "BAD examples: `async () => { await fetch(...) }`, `require('react-native')`, `console.log('\\u{1F600}')`\n" +
1984
- "SEE ALSO: call get_usage_guide(topic=\"state\") for the full app-state playbook.",
1985
- inputSchema: {
1986
- expression: z
1987
- .string()
1988
- .describe("JavaScript expression to execute. Must be valid Hermes syntax — no require(), no async/await, no emoji/non-ASCII in strings. Use globals discovered via list_debug_globals."),
1989
- awaitPromise: z.coerce
1990
- .boolean()
1991
- .optional()
1992
- .default(true)
1993
- .describe("Whether to await promises (default: true)"),
1994
- maxResultLength: z.coerce
1995
- .number()
1996
- .optional()
1997
- .default(2000)
1998
- .describe("Max characters in result (default: 2000, set to 0 for unlimited). Tip: For large objects like Redux stores, use inspect_global instead or set higher limit."),
1999
- verbose: z
2000
- .boolean()
2001
- .optional()
2002
- .default(false)
2003
- .describe("Disable result truncation. Tip: Be cautious - Redux stores or large state can return 10KB+."),
2004
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2005
- }
2006
- }, async ({ expression, awaitPromise, maxResultLength, verbose, device }) => {
2007
- const result = await executeInApp(expression, awaitPromise, {}, device);
2008
- if (!result.success) {
2009
- let errorText = `Error: ${result.error}`;
2010
- // If the error is a ReferenceError (accessing a global that doesn't exist),
2011
- // guide the agent to expose the variable as a global first
2012
- if (result.error?.includes("ReferenceError")) {
2013
- errorText +=
2014
- "\n\nNOTE: This variable is not exposed as a global. To access it, first assign it to a global variable in your app code (e.g., `globalThis.__MY_VAR__ = myVar;`), then use execute_in_app to read `__MY_VAR__`. You can also use list_debug_globals to see what globals ARE currently available.";
2015
- }
2016
- return {
2017
- content: [
2018
- {
2019
- type: "text",
2020
- text: errorText
2021
- }
2022
- ],
2023
- isError: true,
2024
- // Include expression as context for telemetry (helps debug syntax errors)
2025
- _errorContext: expression
2026
- };
2027
- }
2028
- let resultText = result.result ?? "undefined";
2029
- // Apply truncation unless verbose or unlimited
2030
- if (!verbose && maxResultLength > 0 && resultText.length > maxResultLength) {
2031
- resultText =
2032
- resultText.slice(0, maxResultLength) + `... [truncated: ${result.result?.length ?? 0} chars total]`;
2033
- }
2034
- return {
2035
- content: [
2036
- {
2037
- type: "text",
2038
- text: resultText
2039
- }
2040
- ]
2041
- };
2042
- },
2043
- // Empty result detector: successful execution but no meaningful output
2044
- (result) => {
2045
- if (result?.isError)
2046
- return false;
2047
- const text = result?.content?.[0]?.text;
2048
- return text === undefined || text === "" || text === "undefined" || text === "null";
2049
- });
2050
- // Tool: List debug globals available in the app
2051
- registerToolWithTelemetry("list_debug_globals", {
2052
- 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.\n" +
2053
- "PURPOSE: Enumerate the app's `globalThis.*` surface so you know which stores, clients, and debug hooks you can drill into.\n" +
2054
- "WHEN TO USE: Start of a state-debugging session, or when you don't know whether the app exposes a Redux/Apollo/Zustand handle.\n" +
2055
- "WORKFLOW: list_debug_globals -> inspect_global(objectName=\"...\") -> execute_in_app for reads/mutations.\n" +
2056
- "SDK INTEGRATION: If the app uses `react-native-ai-devtools-sdk` and called `init({ stores, navigation, custom })`, the response includes an `sdk.paths` array of ready-to-use dotted paths (e.g. `__RN_AI_DEVTOOLS__.stores.redux`, `__RN_AI_DEVTOOLS__.custom.mmkv`). Pass these straight to inspect_global or execute_in_app.\n" +
2057
- "OUTPUT SHAPE: { sdk: { version, capabilities, paths, hint } | null, categories: { 'Apollo Client': [...], 'Redux': [...], 'Other Debug': [...], ... } }\n" +
2058
- "LIMITATIONS: Only sees variables explicitly assigned to a global (e.g., `globalThis.store = store`). Module-scoped state is invisible — expose it first or use the SDK's init().\n" +
2059
- "GOOD: list_debug_globals()\n" +
2060
- "BAD: Calling before scan_metro — needs a live connection.\n" +
2061
- "SEE ALSO: call get_usage_guide(topic=\"state\") for the full app-state playbook.",
2062
- inputSchema: {
2063
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2064
- }
2065
- }, async ({ device }) => {
2066
- const result = await listDebugGlobals(device);
2067
- if (!result.success) {
2068
- return {
2069
- content: [
2070
- {
2071
- type: "text",
2072
- text: `Error: ${result.error}`
2073
- }
2074
- ],
2075
- isError: true
2076
- };
2077
- }
2078
- return {
2079
- content: [
2080
- {
2081
- type: "text",
2082
- text: `Available debug globals in the app:\n\n${result.result}`
2083
- }
2084
- ]
2085
- };
2086
- });
2087
- // Tool: Inspect a global object to see its properties and types
2088
- registerToolWithTelemetry("inspect_global", {
2089
- description: "Inspect a global object (or a dotted path into one) to see its properties, types, and whether they are callable functions. Use this BEFORE calling methods on unfamiliar objects to avoid errors.\n" +
2090
- "PURPOSE: Surface the shape of a global (Apollo client, Redux store, Expo Router, SDK-registered store, etc.) — keys, types, and which members are callable — without executing arbitrary code.\n" +
2091
- "WHEN TO USE: After list_debug_globals identifies a promising global and before you try execute_in_app on it.\n" +
2092
- "WORKFLOW: list_debug_globals -> inspect_global(objectName=\"__APOLLO_CLIENT__\") -> execute_in_app(\"__APOLLO_CLIENT__.cache.extract()\").\n" +
2093
- "DOTTED PATHS: Pass dotted paths to drill into the SDK surface, e.g. inspect_global({ objectName: \"__RN_AI_DEVTOOLS__.stores.redux\" }) or \"__RN_AI_DEVTOOLS__.custom.mmkv\". Only identifier paths are accepted — for arbitrary expressions, use execute_in_app.\n" +
2094
- "LIMITATIONS: Only reads one level deep; nested objects show as a 100-char JSON preview — re-inspect the child path. Returns an error object (not a throw) when the path doesn't resolve.\n" +
2095
- "GOOD: inspect_global({ objectName: \"__APOLLO_CLIENT__\" }) | inspect_global({ objectName: \"__RN_AI_DEVTOOLS__.stores.redux\" })\n" +
2096
- "BAD: inspect_global({ objectName: \"store.getState()\" }) — call expressions aren't supported; use execute_in_app.\n" +
2097
- "SEE ALSO: call get_usage_guide(topic=\"state\") for the full app-state playbook.",
2098
- inputSchema: {
2099
- objectName: z
2100
- .string()
2101
- .describe("Identifier or dotted path of the global to inspect (e.g., '__APOLLO_CLIENT__', '__RN_AI_DEVTOOLS__.stores.redux', '__RN_AI_DEVTOOLS__.custom.mmkv')"),
2102
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2103
- }
2104
- }, async ({ objectName, device }) => {
2105
- const result = await inspectGlobal(objectName, device);
2106
- if (!result.success) {
2107
- let errorText = `Error: ${result.error}`;
2108
- // If the global (or path) doesn't resolve, guide the agent toward
2109
- // either exposing it manually or registering it via the SDK.
2110
- const looksMissing = result.error?.includes("ReferenceError") || result.error?.includes("NotFound") || result.error?.includes("not found");
2111
- if (looksMissing) {
2112
- const isPath = objectName.includes(".");
2113
- if (isPath) {
2114
- errorText += `\n\nNOTE: '${objectName}' did not resolve. Call list_debug_globals to confirm the path. If you expected the SDK to expose it, verify init({ stores, navigation, custom }) was called and check the sdk.paths array.`;
2115
- }
2116
- else {
2117
- const suggested = objectName.replace(/^__/, "").replace(/__$/, "");
2118
- errorText += `\n\nNOTE: '${objectName}' is not exposed as a global variable. Either (a) assign it in app code (\`globalThis.${objectName} = ${suggested};\`), or (b) register it via react-native-ai-devtools-sdk's init({ custom: { ${suggested}: ${suggested} } }) and access it as __RN_AI_DEVTOOLS__.custom.${suggested}. Then call list_debug_globals to confirm.`;
2119
- }
2120
- }
2121
- return {
2122
- content: [
2123
- {
2124
- type: "text",
2125
- text: errorText
2126
- }
2127
- ],
2128
- isError: true
2129
- };
2130
- }
2131
- return {
2132
- content: [
2133
- {
2134
- type: "text",
2135
- text: `Properties of ${objectName}:\n\n${result.result}`
2136
- }
2137
- ]
2138
- };
2139
- });
2140
- // ============================================================================
2141
- // Redux Tools
2142
- // ============================================================================
2143
- registerToolWithTelemetry("redux_dispatch", {
2144
- description: "Dispatch a Redux action to the store bound to the app's <Provider>, triggering useSelector subscribers and React re-renders. Resolves the live store by walking the React fiber tree on each call (no SDK setup needed; works even if no store was registered with init()).\n" +
2145
- "PURPOSE: Drive state-controlled UI (loaders, modals, toasts, error overlays) without exercising the real flow (network, OTP, etc.).\n" +
2146
- "WHY THIS EXISTS: __RN_AI_DEVTOOLS__.stores.redux often holds a different store reference than the one passed to <Provider>, so dispatching through it updates state but does NOT notify react-redux subscribers. This tool dispatches through the actual Provider store, so views re-render.\n" +
2147
- "WHEN TO USE: Verify state-driven UI by seeding redux state directly. Example: dispatch app/setIsLoading: true, then ios_screenshot to confirm the loader rendered.\n" +
2148
- "WORKFLOW: redux_dispatch({ action: { type: 'app/setIsLoading', payload: true } }) -> ios_screenshot -> redux_dispatch({ action: { type: 'app/setIsLoading', payload: false } }).\n" +
2149
- "LIMITATIONS: Requires React DevTools hook (dev mode). Action must be plain JSON-serializable (no thunks/functions). If the app has multiple <Provider> roots, pass storeIndex (default 0).\n" +
2150
- "GOOD: redux_dispatch({ action: { type: 'app/setIsLoading', payload: true } })\n" +
2151
- "BAD: redux_dispatch({ action: () => ... }) — actions must be plain objects; for thunks use execute_in_app to call your action creator.",
2152
- inputSchema: {
2153
- action: z
2154
- .record(z.unknown())
2155
- .describe("Plain JSON-serializable Redux action object, e.g. { type: 'app/setIsLoading', payload: true }."),
2156
- storeIndex: z
2157
- .number()
2158
- .int()
2159
- .min(0)
2160
- .optional()
2161
- .describe("Index of the Provider store to dispatch to when the app has multiple <Provider> roots (default: 0)."),
2162
- returnPath: z
2163
- .string()
2164
- .optional()
2165
- .describe("Optional dotted path into the post-dispatch state to return for verification (e.g. 'app' or 'auth.user'). Omit to skip returning state — keeps the response small. Use redux_get_state for ad-hoc reads."),
2166
- device: z
2167
- .string()
2168
- .optional()
2169
- .describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2170
- }
2171
- }, async ({ action, storeIndex, returnPath, device }) => {
2172
- const result = await reduxDispatch({ action: action, storeIndex, returnPath, device });
2173
- if (!result.success) {
2174
- return {
2175
- content: [{ type: "text", text: `Error: ${result.error ?? "Unknown error"}` }],
2176
- isError: true
2177
- };
2178
- }
2179
- const ack = `Dispatched to store ${result.storeIndex} of ${result.storeCount}: ${JSON.stringify(result.previousAction)}`;
2180
- const stateLine = returnPath
2181
- ? `\n\nState at '${returnPath}':\n${JSON.stringify(result.state, null, 2)}`
2182
- : "";
2183
- return { content: [{ type: "text", text: ack + stateLine }] };
2184
- });
2185
- registerToolWithTelemetry("redux_get_state", {
2186
- description: "Read state from the Redux store bound to the app's <Provider>, resolved live via the fiber tree (same store redux_dispatch targets).\n" +
2187
- "PURPOSE: Inspect the current app state without relying on __RN_AI_DEVTOOLS__.stores.redux (which may point at a different store instance than the Provider).\n" +
2188
- "WHEN TO USE: Verify state shape before/after redux_dispatch, or check what slice keys exist before crafting an action.\n" +
2189
- "WORKFLOW: redux_get_state() -> craft action -> redux_dispatch -> redux_get_state({ path: 'app' }) to confirm.\n" +
2190
- "LIMITATIONS: Requires React DevTools hook (dev mode). State must be JSON-serializable; non-serializable values are replaced with an error marker.\n" +
2191
- "GOOD: redux_get_state({ path: 'app' })\n" +
2192
- "BAD: redux_get_state({ path: 'app.isLoading.0' }) when isLoading is a boolean — path traversal returns undefined.",
2193
- inputSchema: {
2194
- storeIndex: z
2195
- .number()
2196
- .int()
2197
- .min(0)
2198
- .optional()
2199
- .describe("Index of the Provider store to read from when the app has multiple <Provider> roots (default: 0)."),
2200
- path: z
2201
- .string()
2202
- .optional()
2203
- .describe("Optional dotted path into state (e.g. 'app' or 'auth.user'). Omit for the full state."),
2204
- device: z
2205
- .string()
2206
- .optional()
2207
- .describe("Target device name (substring match). Omit for default device.")
2208
- }
2209
- }, async ({ storeIndex, path, device }) => {
2210
- const result = await reduxGetState({ storeIndex, path, device });
2211
- if (!result.success) {
2212
- return {
2213
- content: [{ type: "text", text: `Error: ${result.error ?? "Unknown error"}` }],
2214
- isError: true
2215
- };
2216
- }
2217
- const header = `Store ${result.storeIndex} of ${result.storeCount}${path ? ` at path '${path}'` : ""}:`;
2218
- return { content: [{ type: "text", text: `${header}\n\n${JSON.stringify(result.state, null, 2)}` }] };
2219
- });
2220
- // ============================================================================
2221
- // React Component Inspection Tools
2222
- // ============================================================================
2223
- // Tool: Get the React component tree
2224
- registerToolWithTelemetry("get_component_tree", {
2225
- 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.\n" +
2226
- "PURPOSE: Expose the entire fiber tree — including providers, navigators, and off-screen subtrees — when get_screen_layout's visible-only view isn't enough.\n" +
2227
- "WHEN TO USE: Debugging context propagation, navigation wrappers, hidden modals, or when you need to understand the full React architecture.\n" +
2228
- "WORKFLOW: get_component_tree(structureOnly=true) for overview -> find_components for targeted lookup -> inspect_component for props/state.\n" +
2229
- "LIMITATIONS: Full trees can be large; always start with structureOnly=true. Ignores non-React native views. Minified builds return display names that may be opaque.\n" +
2230
- "GOOD: get_component_tree({ structureOnly: true })\n" +
2231
- "BAD: get_component_tree({ includeProps: true, includeStyles: true }) on a large app — likely hits response-size limits. Prefer inspect_component for specific nodes.\n" +
2232
- "SEE ALSO: call get_usage_guide(topic=\"inspect\") for the full component-inspect playbook.",
2233
- inputSchema: {
2234
- structureOnly: z
2235
- .boolean()
2236
- .optional()
2237
- .default(false)
2238
- .describe("Return ultra-compact structure with just component names (no props, styles, or paths). Use this first for overview, then drill down with inspect_component."),
2239
- maxDepth: z
2240
- .number()
2241
- .optional()
2242
- .describe("Maximum tree depth (default: 5000)"),
2243
- includeProps: z
2244
- .boolean()
2245
- .optional()
2246
- .default(false)
2247
- .describe("Include component props (excluding children and style). Ignored if structureOnly=true."),
2248
- includeStyles: z
2249
- .boolean()
2250
- .optional()
2251
- .default(false)
2252
- .describe("Include layout styles (padding, margin, flex, etc.). Ignored if structureOnly=true."),
2253
- hideInternals: z
2254
- .boolean()
2255
- .optional()
2256
- .default(true)
2257
- .describe("Hide internal RN components (RCTView, RNS*, Animated, etc.) for cleaner output (default: true)"),
2258
- format: z
2259
- .enum(["json", "tonl"])
2260
- .optional()
2261
- .default("tonl")
2262
- .describe("Output format: 'json' or 'tonl' (default, compact indented tree). Ignored if structureOnly=true."),
2263
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2264
- }
2265
- }, async ({ structureOnly, maxDepth, includeProps, includeStyles, hideInternals, format, device }) => {
2266
- const result = await getComponentTree({
2267
- structureOnly,
2268
- maxDepth,
2269
- includeProps,
2270
- includeStyles,
2271
- hideInternals,
2272
- format,
2273
- device
2274
- });
2275
- if (!result.success) {
2276
- return {
2277
- content: [
2278
- {
2279
- type: "text",
2280
- text: `Error: ${result.error}`
2281
- }
2282
- ],
2283
- isError: true
2284
- };
2285
- }
2286
- return {
2287
- content: [
2288
- {
2289
- type: "text",
2290
- text: `React Component Tree:\n\n${result.result}`
2291
- }
2292
- ]
2293
- };
2294
- });
2295
- // Tool: Get all pressable elements on screen
2296
- registerToolWithTelemetry("get_pressable_elements", {
2297
- description: "Find all pressable (onPress) and input (TextInput) elements currently visible on screen. Returns component names, ready-to-tap center coordinates in SCREENSHOT PIXELS (same space as ios_screenshot/android_screenshot — pass directly to tap(x, y)), text labels, testID, accessibilityLabel, and a spatial nearbyText hint for icon-only buttons. Each element includes hasLabel (true if it contains text) and isInput (true for TextInput fields).\n" +
2298
- "HELPER — call before `tap` when you need to enumerate candidate elements before committing to a target; not a replacement for tap itself.\n" +
2299
- "PURPOSE: Produce a ready-to-tap inventory of every interactive element on screen with screenshot-pixel coordinates that tap(x, y) accepts directly.\n" +
2300
- "WHEN TO USE: Before tapping icon-only buttons, when text-based tap keeps failing, or to enumerate what the user can actually interact with.\n" +
2301
- "WORKFLOW: ios_screenshot / android_screenshot -> get_pressable_elements -> tap(testID=\"...\") or tap(x, y) using the center coordinates.\n" +
2302
- "LIMITATIONS: Visible-only (off-screen pressables are excluded). Requires a live React connection. Coordinates are in screenshot pixels — the tap tool converts to points internally.\n" +
2303
- "GOOD: get_pressable_elements()\n" +
2304
- "BAD: Calling when tap(testID=\"...\") already works — testID matching is faster and more stable.\n" +
2305
- "SEE ALSO: call get_usage_guide(topic=\"interact\") for the full interaction playbook.",
2306
- inputSchema: {
2307
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2308
- }
2309
- }, async ({ device }) => {
2310
- const result = await getPressableElements({ device });
2311
- if (!result.success) {
2312
- return {
2313
- content: [
2314
- {
2315
- type: "text",
2316
- text: `Error: ${result.error}`
2317
- }
2318
- ],
2319
- isError: true
2320
- };
2321
- }
2322
- const elements = result.parsedElements;
2323
- if (!elements || elements.length === 0) {
2324
- return {
2325
- content: [{ type: "text", text: result.result || "No pressable elements found." }]
2326
- };
2327
- }
2328
- // Resolve target app so we can pick the right simulator / android device
2329
- const targetApp = device ? getConnectedAppByDevice(device) : getFirstConnectedApp();
2330
- if (!targetApp) {
2331
- // No connected app — return raw (points) output; better than nothing
2332
- return {
2333
- content: [{ type: "text", text: result.result || "No pressable elements found." }]
2334
- };
2335
- }
2336
- // Capture a lightweight screenshot to learn scaleFactor (downscale) and dimensions.
2337
- // Conversion: screenshot_px = native_coord * devicePixelRatio / screenshotScale
2338
- let lines = [];
2339
- try {
2340
- if (targetApp.platform === "ios") {
2341
- const udid = targetApp.simulatorUdid;
2342
- const shot = await iosScreenshot(undefined, udid);
2343
- const screenshotScale = shot.scaleFactor || 1;
2344
- const devicePixelRatio = (shot.originalWidth && shot.originalHeight
2345
- ? inferIOSDevicePixelRatio(shot.originalWidth, shot.originalHeight)
2346
- : null) ?? (await getDevicePixelRatio(udid)) ?? 3;
2347
- // Fallback to 59pt (iPhone typical) when the UI driver preflight can't
2348
- // resolve the true inset — matches ios_screenshot's default. Without this
2349
- // shift, react-native-screens modal-presented screens report y relative
2350
- // to content origin and taps land in the status bar instead of the button.
2351
- const safeAreaTop = (await getIOSSafeAreaTop(udid).catch(() => 0)) || 59;
2352
- // Keep the app's lastScreenshot metadata in sync so tap(x, y) uses the
2353
- // same scaleFactor when converting our pixel coords back to points.
2354
- if (shot.originalWidth && shot.originalHeight) {
2355
- targetApp.lastScreenshot = {
2356
- originalWidth: shot.originalWidth,
2357
- originalHeight: shot.originalHeight,
2358
- scaleFactor: screenshotScale
2359
- };
2360
- }
2361
- lines = formatPressablesInPixels(elements, {
2362
- platform: "ios",
2363
- devicePixelRatio,
2364
- screenshotScale,
2365
- safeAreaTop
2366
- });
2367
- }
2368
- else {
2369
- const deviceId = targetApp.deviceInfo?.deviceName;
2370
- const shot = await androidScreenshot(undefined, deviceId);
2371
- const screenshotScale = shot.scaleFactor || 1;
2372
- const density = await androidGetDensity(deviceId).catch(() => ({ density: 160 }));
2373
- const devicePixelRatio = (density.density || 160) / 160;
2374
- if (shot.originalWidth && shot.originalHeight) {
2375
- targetApp.lastScreenshot = {
2376
- originalWidth: shot.originalWidth,
2377
- originalHeight: shot.originalHeight,
2378
- scaleFactor: screenshotScale
2379
- };
2380
- }
2381
- lines = formatPressablesInPixels(elements, {
2382
- platform: "android",
2383
- devicePixelRatio,
2384
- screenshotScale,
2385
- safeAreaTop: 0
2386
- });
2387
- }
2388
- }
2389
- catch {
2390
- // Fallback to points if screenshot/metadata unavailable
2391
- return {
2392
- content: [{ type: "text", text: result.result || "No pressable elements found." }]
2393
- };
2394
- }
2395
- const iconCount = elements.filter((e) => !e.hasLabel).length;
2396
- const labeledCount = elements.length - iconCount;
2397
- const summary = `Found ${elements.length} pressable elements (${iconCount} icon-only, ${labeledCount} with text labels)`;
2398
- const text = [summary, "", ...lines].join("\n");
2399
- return {
2400
- content: [{ type: "text", text }]
2401
- };
2402
- });
2403
- function formatPressablesInPixels(elements, opts) {
2404
- const { platform, devicePixelRatio, screenshotScale, safeAreaTop } = opts;
2405
- const out = [];
2406
- for (let i = 0; i < elements.length; i++) {
2407
- const el = elements[i];
2408
- // react-native-screens modal presentations report y relative to content origin;
2409
- // shift into the window frame when the measurement falls inside the safe-area band.
2410
- let cy = el.center.y;
2411
- let fy = el.frame.y;
2412
- if (platform === "ios" && safeAreaTop > 0) {
2413
- if (cy < safeAreaTop)
2414
- cy += safeAreaTop;
2415
- if (fy < safeAreaTop)
2416
- fy += safeAreaTop;
2417
- }
2418
- const toPx = (v) => Math.round((v * devicePixelRatio) / screenshotScale);
2419
- const cx = toPx(el.center.x);
2420
- const cyPx = toPx(cy);
2421
- const fx = toPx(el.frame.x);
2422
- const fyPx = toPx(fy);
2423
- const fw = toPx(el.frame.width);
2424
- const fh = toPx(el.frame.height);
2425
- const num = i + 1;
2426
- const label = el.hasLabel
2427
- ? `"${el.text}"`
2428
- : el.intent
2429
- ? `(${el.intent} icon)`
2430
- : "(icon/image)";
2431
- const ids = [];
2432
- if (el.testID)
2433
- ids.push(`testID="${el.testID}"`);
2434
- if (el.accessibilityLabel)
2435
- ids.push(`a11y="${el.accessibilityLabel}"`);
2436
- const idStr = ids.length > 0 ? ` [${ids.join(", ")}]` : "";
2437
- const inputStr = el.isInput ? " (input)" : "";
2438
- const wrapperStr = el.isWrapper ? " [wrapper — skip unless dismissing keyboard]" : "";
2439
- const nearPart = el.nearbyText ? ` near "${el.nearbyText}"` : "";
2440
- out.push(`${num}. ${el.component} ${label}${nearPart} — center:(${cx},${cyPx}) frame:(${fx},${fyPx} ${fw}x${fh})${idStr}${inputStr}${wrapperStr}`);
2441
- if (el.path)
2442
- out.push(` path: ${el.path}`);
2443
- }
2444
- return out;
2445
- }
2446
- // Tool: Inspect a specific component by name
2447
- registerToolWithTelemetry("inspect_component", {
2448
- 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." +
2449
- primaryInteractionBanner() + "\n" +
2450
- "PURPOSE: Reveal a mounted component's live props, hook state, and (optionally) child subtree so you can reason about why it renders the way it does.\n" +
2451
- "WHEN TO USE: User asks \"why is this button disabled\", \"what props does X receive\", or you need to confirm state changed after a tap.\n" +
2452
- "WORKFLOW: get_screen_layout or find_components -> inspect_component(componentName=\"Foo\") -> tap or execute_in_app to change state -> inspect_component again.\n" +
2453
- "LIMITATIONS: Requires the component to be currently mounted in the fiber tree. Name matching is exact; use find_components for fuzzy/regex lookup.\n" +
2454
- "GOOD: inspect_component({ componentName: \"SneakerCard\", index: 0 })\n" +
2455
- "BAD: inspect_component({ componentName: \"Card\" }) when many Card instances exist — pass index or narrow via find_components.\n" +
2456
- "SEE ALSO: call get_usage_guide(topic=\"inspect\") for the full component-inspect playbook.",
2457
- inputSchema: {
2458
- componentName: z
2459
- .string()
2460
- .describe("Name of the component to inspect (e.g., 'Button', 'HomeScreen', 'FlatList')"),
2461
- index: z
2462
- .number()
2463
- .optional()
2464
- .default(0)
2465
- .describe("If multiple instances exist, which one to inspect (0-based index, default: 0)"),
2466
- includeState: z
2467
- .boolean()
2468
- .optional()
2469
- .default(true)
2470
- .describe("Include component state/hooks (default: true)"),
2471
- includeChildren: z.boolean().optional().default(false).describe("Include children component tree"),
2472
- childrenDepth: z
2473
- .number()
2474
- .optional()
2475
- .default(1)
2476
- .describe("How many levels deep to show children (default: 1 = direct children only, 2+ = nested tree)"),
2477
- shortPath: z.boolean().optional().default(true).describe("Show only last 3 path segments (default: true)"),
2478
- simplifyHooks: z
2479
- .boolean()
2480
- .optional()
2481
- .default(true)
2482
- .describe("Simplify hooks output by hiding effects and reducing depth (default: true)"),
2483
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2484
- }
2485
- }, async ({ componentName, index, includeState, includeChildren, childrenDepth, shortPath, simplifyHooks, device }) => {
2486
- const result = await inspectComponent(componentName, {
2487
- index,
2488
- includeState,
2489
- includeChildren,
2490
- childrenDepth,
2491
- shortPath,
2492
- simplifyHooks,
2493
- device
2494
- });
2495
- if (!result.success) {
2496
- return {
2497
- content: [
2498
- {
2499
- type: "text",
2500
- text: `Error: ${result.error}`
2501
- }
2502
- ],
2503
- isError: true
2504
- };
2505
- }
2506
- return {
2507
- content: [
2508
- {
2509
- type: "text",
2510
- text: `Component Inspection: ${componentName}\n\n${result.result}`
2511
- }
2512
- ]
2513
- };
2514
- });
2515
- // Tool: Find components matching a pattern
2516
- registerToolWithTelemetry("find_components", {
2517
- 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." +
2518
- primaryInteractionBanner() + "\n" +
2519
- "PURPOSE: Fast regex search over the entire fiber tree — including off-screen and wrapper components — to locate every instance of a component by name.\n" +
2520
- "WHEN TO USE: You know roughly what the component is called (e.g., \"Button\", \"Screen$\") but not where it lives, or you need counts/paths before drilling in with inspect_component.\n" +
2521
- "WORKFLOW: get_screen_layout (orient) -> find_components(pattern=\"...\") -> inspect_component(componentName=\"...\", index=N).\n" +
2522
- "LIMITATIONS: Matches the React display name only; minified builds may return opaque names. Large result sets — use maxResults or a tighter pattern.\n" +
2523
- "GOOD: find_components({ pattern: \"Button\" }); find_components({ pattern: \"Screen$\" })\n" +
2524
- "BAD: find_components({ pattern: \".*\" }) — floods the response; narrow the regex.\n" +
2525
- "SEE ALSO: call get_usage_guide(topic=\"inspect\") for the full component-inspect playbook.",
2526
- inputSchema: {
2527
- pattern: z
2528
- .string()
2529
- .describe("Regex pattern to match component names (case-insensitive). Examples: 'Button', 'Screen$', 'List.*Item'"),
2530
- maxResults: z.number().optional().default(20).describe("Maximum number of results to return (default: 20)"),
2531
- includeLayout: z
2532
- .boolean()
2533
- .optional()
2534
- .default(false)
2535
- .describe("Include layout styles (padding, margin, flex) for each matched component"),
2536
- shortPath: z.boolean().optional().default(true).describe("Show only last 3 path segments (default: true)"),
2537
- summary: z
2538
- .boolean()
2539
- .optional()
2540
- .default(false)
2541
- .describe("Return only component counts by name instead of full list (default: false)"),
2542
- format: z
2543
- .enum(["json", "tonl"])
2544
- .optional()
2545
- .default("tonl")
2546
- .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)"),
2547
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2548
- }
2549
- }, async ({ pattern, maxResults, includeLayout, shortPath, summary, format, device }) => {
2550
- const result = await findComponents(pattern, { maxResults, includeLayout, shortPath, summary, format, device });
2551
- if (!result.success) {
2552
- return {
2553
- content: [
2554
- {
2555
- type: "text",
2556
- text: `Error: ${result.error}`
2557
- }
2558
- ],
2559
- isError: true
2560
- };
2561
- }
2562
- return {
2563
- content: [
2564
- {
2565
- type: "text",
2566
- text: `Find Components (pattern: "${pattern}"):\n\n${result.result}`
2567
- }
2568
- ]
2569
- };
2570
- });
2571
- // Tool: Toggle Element Inspector programmatically
2572
- registerToolWithTelemetry("toggle_element_inspector", {
2573
- description: "Toggle React Native's Element Inspector overlay on/off. Rarely needed directly — get_inspector_selection auto-toggles the overlay on for capture and back off afterward. Use only for edge cases (e.g., leaving the overlay visible on screen for a user-facing screenshot).\n" +
2574
- "PURPOSE: Manual control over the on-device inspector overlay.\n" +
2575
- "WHEN TO USE: Only for special cases like capturing a screenshot WITH the inspector visible. Normal inspection workflows should call get_inspector_selection directly.\n" +
2576
- "SEE ALSO: call get_usage_guide(topic=\"inspect\") for the full component-inspect playbook.",
2577
- inputSchema: {
2578
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2579
- }
2580
- }, async ({ device }) => {
2581
- const result = await toggleElementInspector(device);
2582
- if (!result.success) {
2583
- return {
2584
- content: [
2585
- {
2586
- type: "text",
2587
- text: `Error: ${result.error}`
2588
- }
2589
- ],
2590
- isError: true
2591
- };
2592
- }
2593
- try {
2594
- const parsed = JSON.parse(result.result || "{}");
2595
- if (parsed.error) {
2596
- return {
2597
- content: [
2598
- {
2599
- type: "text",
2600
- text: `Failed to toggle Element Inspector: ${parsed.error}`
2601
- }
2602
- ],
2603
- isError: true
2604
- };
2605
- }
2606
- return {
2607
- content: [
2608
- {
2609
- type: "text",
2610
- text: parsed.message || "Element Inspector toggled successfully"
2611
- }
2612
- ]
2613
- };
2614
- }
2615
- catch {
2616
- return {
2617
- content: [
2618
- {
2619
- type: "text",
2620
- text: result.result || "Element Inspector toggled"
2621
- }
2622
- ]
2623
- };
2624
- }
2625
- });
2626
- // Tool: Get currently selected element from Element Inspector
2627
- registerToolWithTelemetry("get_inspector_selection", {
2628
- description: "Identify the React component at a screen location AND read its full styling. Returns RN's curated owner-tree hierarchy with PER-COMPONENT STYLE (padding, margin, border, layout, colors, fontSize, etc.) — the same rich data the on-device Element Inspector shows. Works on Bridgeless / new arch by invoking RN's inspector programmatically. If x/y provided: toggles the overlay on, captures the selection, and toggles it back off (no screenshot pollution). If no coordinates: reads the current selection from a manually-driven overlay.\n" +
2629
- "PURPOSE: Identity + styling. Answers \"what is this and how is it styled?\" — the primary tool for visual/style debugging at a coordinate.\n" +
2630
- "WHEN TO USE: You see a visual issue at a pixel and want the component name AND its style values (e.g. \"why is borderRadius 14 instead of 16?\"). Best for style/CSS-style debugging.\n" +
2631
- "WORKFLOW: ios_screenshot / android_screenshot -> note the suspect pixel -> get_inspector_selection(x, y) -> edit the returned style values.\n" +
2632
- "LIMITATIONS: Requires RN dev mode (__DEV__). x/y are in points/dp. Brief overlay flicker (~600ms total). Source file paths are pre-wired but null on React 19 (where _debugSource was dropped); component name + style is always returned.\n" +
2633
- "DIFFERENCE vs inspect_at_point: get_inspector_selection returns RICH STYLE per ancestor (the inspector's curated view) but only ONE frame (the inspected element). inspect_at_point returns FRAME PER ANCESTOR plus PROPS (handlers, refs, non-style props) but no rich style merging. Use this tool for style/identity; use inspect_at_point for layout measurements and props.\n" +
2634
- "GOOD: get_inspector_selection({ x: 180, y: 420 }) // \"what is this gradient card and what's its borderRadius?\"\n" +
2635
- "BAD: Calling it in a tight loop — prefer inspect_at_point (no overlay toggle, faster, no visual side effect).\n" +
2636
- "SEE ALSO: call get_usage_guide(topic=\"inspect\") for the full component-inspect playbook.",
2637
- inputSchema: {
2638
- x: z
2639
- .number()
2640
- .optional()
2641
- .describe("X coordinate (in points). If provided with y, auto-taps at this location."),
2642
- y: z
2643
- .number()
2644
- .optional()
2645
- .describe("Y coordinate (in points). If provided with x, auto-taps at this location."),
2646
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2647
- }
2648
- }, async ({ x, y, device }) => {
2649
- if (!hasMetro()) {
2650
- const hint = await metroMissingHintIfAbsent("get_inspector_selection");
2651
- return {
2652
- content: [{ type: "text", text: `Inspector selection unavailable.${hint}` }],
2653
- isError: true
2654
- };
2655
- }
2656
- // Coordinate path: use fiber-based hit testing (works on Bridgeless / new arch
2657
- // where RN's built-in inspector cannot populate hierarchy via UIManager.findSubviewIn).
2658
- // Avoids toggling the on-device overlay so screenshots stay clean.
2659
- const result = x !== undefined && y !== undefined
2660
- ? await getInspectorSelectionAtPoint(x, y, device)
2661
- : await getInspectorSelection(device);
2662
- if (!result.success) {
2663
- return {
2664
- content: [{ type: "text", text: `Error: ${result.error}` }],
2665
- isError: true
2666
- };
2667
- }
2668
- try {
2669
- const parsed = JSON.parse(result.result || "{}");
2670
- if (parsed.error) {
2671
- const hint = parsed.hint ? `\n\n${parsed.hint}` : "";
2672
- return {
2673
- content: [{ type: "text", text: `${parsed.error}${hint}` }],
2674
- isError: true
2675
- };
2676
- }
2677
- let output = `Element: ${parsed.element}\n`;
2678
- output += `Path: ${parsed.path}\n`;
2679
- if (parsed.frame) {
2680
- const f = parsed.frame;
2681
- output += `Frame: (${f.left?.toFixed(1)}, ${f.top?.toFixed(1)}) ${f.width?.toFixed?.(1) ?? f.width}x${f.height?.toFixed?.(1) ?? f.height}\n`;
2682
- }
2683
- if (parsed.style) {
2684
- output += `Style: ${JSON.stringify(parsed.style, null, 2)}\n`;
2685
- }
2686
- if (Array.isArray(parsed.hierarchy) && parsed.hierarchy.length > 0) {
2687
- output += `\nHierarchy:\n`;
2688
- for (const h of parsed.hierarchy) {
2689
- output += ` - ${h.name}`;
2690
- if (h.source)
2691
- output += ` (${h.source})`;
2692
- output += `\n`;
2693
- if (h.style && Object.keys(h.style).length > 0) {
2694
- const styleStr = JSON.stringify(h.style);
2695
- output += ` style: ${styleStr.length > 300 ? styleStr.slice(0, 300) + "…" : styleStr}\n`;
2696
- }
2697
- }
2698
- }
2699
- return {
2700
- content: [{ type: "text", text: output }]
2701
- };
2702
- }
2703
- catch {
2704
- return {
2705
- content: [{ type: "text", text: result.result || "No selection data" }]
2706
- };
2707
- }
2708
- });
2709
- // Tool: Inspect component at coordinates (like Element Inspector)
2710
- registerToolWithTelemetry("inspect_at_point", {
2711
- description: "Inspect layout AND props at (x, y). Returns FRAME PER ANCESTOR (position/size in dp for every ancestor that hit-tested the point), plus the innermost component's PROPS (handlers as `[Function]`, refs, custom props like onPress/data/testID). Pure JS hit-test via fiber tree + measureInWindow — no on-device overlay toggled, zero visual side effect. Works on Paper and Fabric.\n" +
2712
- "PURPOSE: Layout/props diagnosis. Answers \"where is each ancestor positioned, and what props does the touched component expose?\"\n" +
2713
- "WHEN TO USE: A button is clipped, an element's hit area is wrong, an animated frame is unexpected — or you need handler/ref/non-style props that the inspector doesn't surface. Also preferred for tight loops or before/after comparisons (no overlay flicker).\n" +
2714
- "WORKFLOW: ios_screenshot -> find suspect pixel -> convert to dp (pixel / pixelRatio) -> inspect_at_point(x, y).\n" +
2715
- "LIMITATIONS: Coordinates MUST be in dp, not screenshot pixels — wrong unit = wrong component. Style is shown as a reference (no rich merging) — for style debugging use get_inspector_selection.\n" +
2716
- "DIFFERENCE vs get_inspector_selection: inspect_at_point returns FRAME PER ANCESTOR + PROPS, no overlay flicker, no rich style. get_inspector_selection returns RICH STYLE per ancestor (padding/margin/border) but only ONE frame and toggles the overlay briefly. Use get_inspector_selection for style/identity; use this tool for layout measurements and props.\n" +
2717
- "GOOD: inspect_at_point({ x: 205, y: 360 }) // \"why is this button's hit area too small?\"\n" +
2718
- "BAD: inspect_at_point({ x: 540, y: 960 }) // raw screenshot pixels — picks the wrong node.\n" +
2719
- "SEE ALSO: call get_usage_guide(topic=\"inspect\") for the full component-inspect playbook.",
2720
- inputSchema: {
2721
- x: z
2722
- .number()
2723
- .describe("X coordinate in dp (logical pixels). Convert from screenshot pixels by dividing by the device pixel ratio."),
2724
- y: z
2725
- .number()
2726
- .describe("Y coordinate in dp (logical pixels). Convert from screenshot pixels by dividing by the device pixel ratio."),
2727
- includeProps: z
2728
- .boolean()
2729
- .optional()
2730
- .default(true)
2731
- .describe("Include component props in the output (default: true)"),
2732
- includeFrame: z
2733
- .boolean()
2734
- .optional()
2735
- .default(true)
2736
- .describe("Include position/dimensions (frame) in the output (default: true)"),
2737
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
2738
- }
2739
- }, async ({ x, y, includeProps, includeFrame, device }) => {
2740
- const result = await inspectAtPoint(x, y, { includeProps, includeFrame, device });
2741
- if (!result.success) {
2742
- return {
2743
- content: [
2744
- {
2745
- type: "text",
2746
- text: `Error: ${result.error}`
2747
- }
2748
- ],
2749
- isError: true
2750
- };
2751
- }
2752
- // Parse the result to check for errors in the response
2753
- try {
2754
- const parsed = JSON.parse(result.result || "{}");
2755
- if (parsed.error) {
2756
- const hint = parsed.hint ? `\n\n${parsed.hint}` : "";
2757
- const alternatives = parsed.alternatives
2758
- ? `\n\nAlternatives:\n${parsed.alternatives.map((a) => ` - ${a}`).join("\n")}`
2759
- : "";
2760
- return {
2761
- content: [
2762
- {
2763
- type: "text",
2764
- text: `Inspect at (${x}, ${y}): ${parsed.error}${hint}${alternatives}`
2765
- }
2766
- ],
2767
- isError: true
2768
- };
2769
- }
2770
- }
2771
- catch {
2772
- // If parsing fails, just return the raw result
2773
- }
2774
- return {
2775
- content: [
2776
- {
2777
- type: "text",
2778
- text: `Element at (${x}, ${y}):\n\n${result.result}`
2779
- }
2780
- ]
2781
- };
2782
- });
2783
- // Tool: Get network requests
2784
- registerToolWithTelemetry("get_network_requests", {
2785
- 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.\n" +
2786
- "PURPOSE: Inspect HTTP traffic the app made since connection — URLs, methods, status codes, and timings — to debug API, auth, and caching issues.\n" +
2787
- "WHEN TO USE: User reports a failed login/load, slow screen, or wrong data. Confirm a request fired, check its status, and pivot to get_request_details for headers/body.\n" +
2788
- "WORKFLOW: scan_metro -> reproduce action -> get_network_requests({ summary: true }) -> get_network_requests({ status: 500 }) or search_network -> get_request_details(id).\n" +
2789
- "LIMITATIONS: Bridgeless targets without the SDK may miss pre-connect requests and response bodies — install react-native-ai-devtools-sdk for full fidelity.\n" +
2790
- "GOOD: get_network_requests({ summary: true }) then get_network_requests({ urlPattern: \"/login\", status: 401 })\n" +
2791
- "BAD: get_network_requests({ maxRequests: 500 }) as the first call — start with summary=true.\n" +
2792
- "SEE ALSO: call get_usage_guide(topic=\"network\") for the full network-inspect playbook.",
2793
- inputSchema: {
2794
- maxRequests: z
2795
- .number()
2796
- .optional()
2797
- .default(50)
2798
- .describe("Maximum number of requests to return (default: 50)"),
2799
- method: z.string().optional().describe("Filter by HTTP method (GET, POST, PUT, DELETE, etc.)"),
2800
- urlPattern: z.string().optional().describe("Filter by URL pattern (case-insensitive substring match)"),
2801
- status: z.number().optional().describe("Filter by HTTP status code (e.g., 200, 401, 500)"),
2802
- format: z
2803
- .enum(["text", "tonl"])
2804
- .optional()
2805
- .default("tonl")
2806
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format, ~30-50% smaller)"),
2807
- summary: z
2808
- .boolean()
2809
- .optional()
2810
- .default(false)
2811
- .describe("Return statistics only (count, methods, domains, status codes). Use for quick overview."),
2812
- device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
2813
- }
2814
- }, async ({ maxRequests, method, urlPattern, status, format, summary, device }) => {
2815
- // Check if SDK is installed — prefer SDK data over CDP/interceptor buffer
2816
- const sdkAvailable = await isSDKInstalled();
2817
- if (sdkAvailable) {
2818
- if (summary) {
2819
- const sdkStats = await getSDKNetworkStats();
2820
- if (sdkStats.success) {
2821
- const s = sdkStats.data;
2822
- const lines = [];
2823
- lines.push(`Total requests: ${s.total}`);
2824
- lines.push(`Completed: ${s.completed}`);
2825
- lines.push(`Errors: ${s.errors}`);
2826
- if (s.avgDuration != null)
2827
- lines.push(`Avg duration: ${s.avgDuration}ms`);
2828
- if (s.byMethod && Object.keys(s.byMethod).length > 0) {
2829
- lines.push("\nBy Method:");
2830
- for (const [m, c] of Object.entries(s.byMethod))
2831
- lines.push(` ${m}: ${c}`);
2832
- }
2833
- if (s.byStatus && Object.keys(s.byStatus).length > 0) {
2834
- lines.push("\nBy Status:");
2835
- for (const [st, c] of Object.entries(s.byStatus))
2836
- lines.push(` ${st}: ${c}`);
2837
- }
2838
- if (s.byDomain && Object.keys(s.byDomain).length > 0) {
2839
- lines.push("\nBy Domain:");
2840
- for (const [d, c] of Object.entries(s.byDomain).sort((a, b) => b[1] - a[1]).slice(0, 10))
2841
- lines.push(` ${d}: ${c}`);
2842
- }
2843
- return { content: [{ type: "text", text: `Network Summary (SDK):\n\n${lines.join("\n")}` }] };
2844
- }
2845
- }
2846
- const sdkResult = await querySDKNetwork({ count: maxRequests, method, urlPattern, status });
2847
- if (sdkResult.success) {
2848
- const entries = sdkResult.data;
2849
- if (entries.length === 0) {
2850
- return { content: [{ type: "text", text: "No network requests captured yet." }] };
2851
- }
2852
- const lines = entries.map((r) => {
2853
- const time = new Date(r.timestamp).toLocaleTimeString();
2854
- const st = r.status ?? "pending";
2855
- const dur = r.duration != null ? `${r.duration}ms` : "-";
2856
- return `[${r.id}] ${time} ${r.method} ${st} ${dur} ${r.url}`;
2857
- });
2858
- return { content: [{ type: "text", text: `Network Requests (${entries.length} entries, SDK):\n\n${lines.join("\n")}` }] };
2859
- }
2860
- }
2861
- // Fallback: read from in-process buffer (CDP/interceptor)
2862
- // Return summary if requested
2863
- if (summary) {
2864
- const stats = getNetworkStats(resolveNetworkBuffer(device));
2865
- let connectionWarning = "";
2866
- if (resolveNetworkBuffer(device).size === 0) {
2867
- const connStatus = await checkAndEnsureConnection(device);
2868
- connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
2869
- if (!sdkAvailable) {
2870
- connectionWarning += "\n\n[TIP] For full network capture including startup requests and response bodies, install the SDK: npm install react-native-ai-devtools-sdk";
2871
- }
2872
- connectionWarning += await metroMissingHintIfAbsent("get_network_requests");
2873
- }
2874
- return {
2875
- content: [
2876
- {
2877
- type: "text",
2878
- text: `Network Summary:\n\n${stats}${connectionWarning}`
2879
- }
2880
- ]
2881
- };
2882
- }
2883
- const { requests, count, formatted } = getNetworkRequests(resolveNetworkBuffer(device), {
2884
- maxRequests,
2885
- method,
2886
- urlPattern,
2887
- status
2888
- });
2889
- // Check connection health
2890
- let connectionWarning = "";
2891
- if (count === 0) {
2892
- const connStatus = await checkAndEnsureConnection(device);
2893
- connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
2894
- if (!sdkAvailable) {
2895
- connectionWarning += "\n\n[TIP] For full network capture including startup requests and response bodies, install the SDK: npm install react-native-ai-devtools-sdk";
2896
- }
2897
- connectionWarning += await metroMissingHintIfAbsent("get_network_requests");
2898
- }
2899
- else {
2900
- const passive = getPassiveConnectionStatus();
2901
- connectionWarning = !passive.connected
2902
- ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
2903
- : "";
2904
- }
2905
- // Check for recent connection gaps
2906
- const warningThresholdMs = 30000; // 30 seconds
2907
- const recentGaps = getRecentGaps(warningThresholdMs);
2908
- let gapWarning = "";
2909
- if (recentGaps.length > 0) {
2910
- const latestGap = recentGaps[recentGaps.length - 1];
2911
- const gapDuration = latestGap.durationMs || Date.now() - latestGap.disconnectedAt.getTime();
2912
- if (latestGap.reconnectedAt) {
2913
- const secAgo = Math.round((Date.now() - latestGap.reconnectedAt.getTime()) / 1000);
2914
- gapWarning = `\n\n[WARNING] Connection was restored ${secAgo}s ago. Some requests may have been missed during the ${formatDuration(gapDuration)} gap.`;
2915
- }
2916
- else {
2917
- gapWarning = `\n\n[WARNING] Connection is currently disconnected. Network data may be incomplete.`;
2918
- }
2919
- }
2920
- // Use TONL format if requested
2921
- if (format === "tonl") {
2922
- const tonlOutput = formatNetworkAsTonl(requests);
2923
- return {
2924
- content: [
2925
- {
2926
- type: "text",
2927
- text: `Network Requests (${count} entries):\n\n${tonlOutput}${gapWarning}${connectionWarning}`
2928
- }
2929
- ]
2930
- };
2931
- }
2932
- return {
2933
- content: [
2934
- {
2935
- type: "text",
2936
- text: `Network Requests (${count} entries):\n\n${formatted}${gapWarning}${connectionWarning}`
2937
- }
2938
- ]
2939
- };
2940
- },
2941
- // Empty result detector: buffer has no entries at all
2942
- () => { let total = 0; for (const b of networkBuffers.values())
2943
- total += b.size; return total === 0; });
2944
- // Tool: Search network requests
2945
- registerToolWithTelemetry("search_network", {
2946
- description: "Search network requests by URL pattern (case-insensitive).\n" +
2947
- "PURPOSE: Filter the network buffer to requests whose URL matches a substring — fast way to find a specific endpoint in a noisy app.\n" +
2948
- "WHEN TO USE: You know part of the URL (e.g., \"/graphql\", \"users\", a domain) and want matching requests across all devices.\n" +
2949
- "WORKFLOW: search_network(urlPattern=\"/api/\") -> get_request_details(requestId=\"...\") for full headers/body.\n" +
2950
- "LIMITATIONS: Matches URL only; for method/status/body filtering use get_network_requests. Bodies are only present when the SDK is installed.\n" +
2951
- "GOOD: search_network({ urlPattern: \"/graphql\" })\n" +
2952
- "BAD: search_network({ urlPattern: \"\" }) — empty pattern matches everything; use get_network_requests instead.\n" +
2953
- "SEE ALSO: call get_usage_guide(topic=\"network\") for the full network-inspect playbook.",
2954
- inputSchema: {
2955
- urlPattern: z.string().describe("URL pattern to search for"),
2956
- maxResults: z.number().optional().default(50).describe("Maximum number of results to return (default: 50)"),
2957
- format: z
2958
- .enum(["text", "tonl"])
2959
- .optional()
2960
- .default("tonl")
2961
- .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)"),
2962
- device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
2963
- }
2964
- }, async ({ urlPattern, maxResults, format, device }) => {
2965
- // Check if SDK is installed — prefer SDK data
2966
- const sdkAvailable = await isSDKInstalled();
2967
- if (sdkAvailable) {
2968
- const sdkResult = await querySDKNetwork({ count: maxResults, urlPattern });
2969
- if (sdkResult.success) {
2970
- const entries = sdkResult.data;
2971
- if (entries.length === 0) {
2972
- return { content: [{ type: "text", text: `No network requests matching "${urlPattern}" found.` }] };
2973
- }
2974
- const lines = entries.map((r) => {
2975
- const time = new Date(r.timestamp).toLocaleTimeString();
2976
- const st = r.status ?? "pending";
2977
- const dur = r.duration != null ? `${r.duration}ms` : "-";
2978
- return `[${r.id}] ${time} ${r.method} ${st} ${dur} ${r.url}`;
2979
- });
2980
- if (format === "tonl") {
2981
- return { content: [{ type: "text", text: `Network search results for "${urlPattern}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
2982
- }
2983
- return { content: [{ type: "text", text: `Network search results for "${urlPattern}" (${entries.length} matches, SDK):\n\n${lines.join("\n")}` }] };
2984
- }
2985
- }
2986
- const { requests, count, formatted } = searchNetworkRequests(resolveNetworkBuffer(device), urlPattern, maxResults);
2987
- // Check connection health
2988
- let connectionWarning = "";
2989
- if (count === 0) {
2990
- const status = await checkAndEnsureConnection(device);
2991
- connectionWarning = status.message ? `\n\n${status.message}` : "";
2992
- connectionWarning += await metroMissingHintIfAbsent("search_network");
2993
- }
2994
- else {
2995
- const passive = getPassiveConnectionStatus();
2996
- connectionWarning = !passive.connected
2997
- ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
2998
- : "";
2999
- }
3000
- // Use TONL format if requested
3001
- if (format === "tonl") {
3002
- const tonlOutput = formatNetworkAsTonl(requests);
3003
- return {
3004
- content: [
3005
- {
3006
- type: "text",
3007
- text: `Network search results for "${urlPattern}" (${count} matches):\n\n${tonlOutput}${connectionWarning}`
3008
- }
3009
- ]
3010
- };
3011
- }
3012
- return {
3013
- content: [
3014
- {
3015
- type: "text",
3016
- text: `Network search results for "${urlPattern}" (${count} matches):\n\n${formatted}${connectionWarning}`
3017
- }
3018
- ]
3019
- };
3020
- });
3021
- // Tool: Get request details
3022
- registerToolWithTelemetry("get_request_details", {
3023
- 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.\n" +
3024
- "PURPOSE: Drill into a single network entry — full request/response headers, body, status, and timing breakdown.\n" +
3025
- "WHEN TO USE: After get_network_requests or search_network returns a suspect ID and you need the payload to diagnose.\n" +
3026
- "WORKFLOW: get_network_requests / search_network -> copy id -> get_request_details(requestId).\n" +
3027
- "LIMITATIONS: Bodies require the react-native-ai-devtools-sdk in the app; on CDP-only targets response bodies are missing. Large bodies are truncated — raise maxBodyLength.\n" +
3028
- "GOOD: get_request_details({ requestId: \"42\", maxBodyLength: 4000 })\n" +
3029
- "BAD: Guessing requestIds — always get them from get_network_requests / search_network first.\n" +
3030
- "SEE ALSO: call get_usage_guide(topic=\"network\") for the full network-inspect playbook.",
3031
- inputSchema: {
3032
- requestId: z.string().describe("The request ID to get details for"),
3033
- maxBodyLength: z.coerce
3034
- .number()
3035
- .optional()
3036
- .default(500)
3037
- .describe("Max characters for request body (default: 500, set to 0 for unlimited). Tip: Large POST bodies (file uploads, base64) can be 10KB+."),
3038
- verbose: z
3039
- .boolean()
3040
- .optional()
3041
- .default(false)
3042
- .describe("Disable body truncation. Tip: Use when you need to inspect full JSON payloads."),
3043
- device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
3044
- }
3045
- }, async ({ requestId, maxBodyLength, verbose, device }) => {
3046
- // Resolve once per call so a mid-call SDK socket flap can't flip
3047
- // routing partway through. Try SDK first (richer data), but ALWAYS
3048
- // fall back to the CDP buffer on a miss — CDP records carry numeric
3049
- // ids the SDK store doesn't know about, and vice versa.
3050
- const sdkAvailable = await isSDKInstalled();
3051
- if (sdkAvailable) {
3052
- const sdkResult = await getSDKNetworkEntry(requestId);
3053
- if (sdkResult.success && sdkResult.data) {
3054
- const r = sdkResult.data;
3055
- const lines = [];
3056
- lines.push(`=== ${r.method} ${r.url} ===`);
3057
- lines.push(`Request ID: ${r.id}`);
3058
- lines.push(`Time: ${new Date(r.timestamp).toISOString()}`);
3059
- lines.push(`Status: ${r.status ?? "pending"} ${r.statusText ?? ""}`);
3060
- if (r.duration != null)
3061
- lines.push(`Duration: ${r.duration}ms`);
3062
- if (r.mimeType)
3063
- lines.push(`Content-Type: ${r.mimeType}`);
3064
- if (r.error)
3065
- lines.push(`Error: ${r.error}`);
3066
- if (r.requestHeaders && Object.keys(r.requestHeaders).length > 0) {
3067
- lines.push("\n--- Request Headers ---");
3068
- for (const [k, v] of Object.entries(r.requestHeaders))
3069
- lines.push(`${k}: ${v}`);
3070
- }
3071
- if (r.requestBody) {
3072
- lines.push("\n--- Request Body ---");
3073
- let body = r.requestBody;
3074
- if (!verbose && maxBodyLength > 0 && body.length > maxBodyLength) {
3075
- body = body.slice(0, maxBodyLength) + `... [truncated: ${r.requestBody.length} chars]`;
3076
- }
3077
- lines.push(body);
3078
- }
3079
- if (r.responseHeaders && Object.keys(r.responseHeaders).length > 0) {
3080
- lines.push("\n--- Response Headers ---");
3081
- for (const [k, v] of Object.entries(r.responseHeaders))
3082
- lines.push(`${k}: ${v}`);
3083
- }
3084
- if (r.responseBody) {
3085
- lines.push("\n--- Response Body ---");
3086
- let body = r.responseBody;
3087
- if (!verbose && maxBodyLength > 0 && body.length > maxBodyLength) {
3088
- body = body.slice(0, maxBodyLength) + `... [truncated: ${r.responseBody.length} chars]`;
3089
- }
3090
- lines.push(body);
3091
- }
3092
- return { content: [{ type: "text", text: lines.join("\n") }] };
3093
- }
3094
- }
3095
- // Fallback: read from in-process buffer
3096
- const request = resolveNetworkBuffer(device).get(requestId);
3097
- if (!request) {
3098
- const status = await checkAndEnsureConnection(device);
3099
- let connectionNote = status.message ? `\n\n${status.message}` : "";
3100
- connectionNote += await metroMissingHintIfAbsent("get_request_details");
3101
- // Enrich with up to 5 recent ids so the agent can pick a real one
3102
- // instead of retrying with the same stale / made-up id (telemetry
3103
- // shows ids like "latest", "js-x6e0-1208", "261" passed in).
3104
- const recent = resolveNetworkBuffer(device).getAll({ count: 5 });
3105
- let recentNote = "";
3106
- if (recent.length > 0) {
3107
- const lines = recent
3108
- .slice()
3109
- .reverse()
3110
- .map((r) => ` - ${r.requestId} — ${r.method} ${r.url}`)
3111
- .join("\n");
3112
- recentNote = `\n\nRecent request ids in buffer (most recent first):\n${lines}\n\nIds are opaque strings — use one of the above, or call get_network_requests / search_network to discover more.`;
3113
- }
3114
- else {
3115
- recentNote =
3116
- "\n\nNo network requests in the buffer for this device. " +
3117
- "Reproduce the action and call get_network_requests first to discover request ids.";
3118
- }
3119
- return {
3120
- content: [
3121
- {
3122
- type: "text",
3123
- text: `Request not found: ${requestId}${connectionNote}${recentNote}`
3124
- }
3125
- ],
3126
- isError: true
3127
- };
3128
- }
3129
- return {
3130
- content: [
3131
- {
3132
- type: "text",
3133
- text: formatRequestDetails(request, { maxBodyLength, verbose })
3134
- }
3135
- ]
3136
- };
3137
- });
3138
- // Tool: Get network stats
3139
- registerToolWithTelemetry("get_network_stats", {
3140
- description: "Get statistics about captured network requests: counts by method, status code, and domain.\n" +
3141
- "PURPOSE: High-level view of traffic shape — totals, error counts, average duration, and top domains — without scanning every request.\n" +
3142
- "WHEN TO USE: To quickly answer \"is there a spike in 5xx?\", \"which domain is chattiest?\", or to sanity-check capture is working.\n" +
3143
- "WORKFLOW: get_network_stats -> if errors > 0: get_network_requests(status=\"500\") -> get_request_details.\n" +
3144
- "LIMITATIONS: Reflects only the buffered window (last 200 entries per device). Resets when clear_network is called.\n" +
3145
- "GOOD: get_network_stats()\n" +
3146
- "BAD: Using it to find a specific request — use search_network or get_network_requests.\n" +
3147
- "SEE ALSO: call get_usage_guide(topic=\"network\") for the full network-inspect playbook.",
3148
- inputSchema: {
3149
- device: z.string().optional().describe("Target device name (substring match). Omit for all devices. Run get_apps to see connected devices.")
3150
- }
3151
- }, async ({ device }) => {
3152
- // Check if SDK is installed — prefer SDK data
3153
- const sdkAvailable = await isSDKInstalled();
3154
- if (sdkAvailable) {
3155
- const sdkStats = await getSDKNetworkStats();
3156
- if (sdkStats.success) {
3157
- const s = sdkStats.data;
3158
- const lines = [];
3159
- lines.push(`Total requests: ${s.total}`);
3160
- lines.push(`Completed: ${s.completed}`);
3161
- lines.push(`Errors: ${s.errors}`);
3162
- if (s.avgDuration != null)
3163
- lines.push(`Avg duration: ${s.avgDuration}ms`);
3164
- if (s.byMethod && Object.keys(s.byMethod).length > 0) {
3165
- lines.push("\nBy Method:");
3166
- for (const [m, c] of Object.entries(s.byMethod))
3167
- lines.push(` ${m}: ${c}`);
3168
- }
3169
- if (s.byStatus && Object.keys(s.byStatus).length > 0) {
3170
- lines.push("\nBy Status:");
3171
- for (const [st, c] of Object.entries(s.byStatus))
3172
- lines.push(` ${st}: ${c}`);
3173
- }
3174
- if (s.byDomain && Object.keys(s.byDomain).length > 0) {
3175
- lines.push("\nBy Domain:");
3176
- for (const [d, c] of Object.entries(s.byDomain).sort((a, b) => b[1] - a[1]).slice(0, 10))
3177
- lines.push(` ${d}: ${c}`);
3178
- }
3179
- return { content: [{ type: "text", text: `Network Statistics (SDK):\n\n${lines.join("\n")}` }] };
3180
- }
3181
- }
3182
- const stats = getNetworkStats(resolveNetworkBuffer(device));
3183
- // Check connection health
3184
- let connectionWarning = "";
3185
- if (resolveNetworkBuffer(device).size === 0) {
3186
- const status = await checkAndEnsureConnection(device);
3187
- connectionWarning = status.message ? `\n\n${status.message}` : "";
3188
- connectionWarning += await metroMissingHintIfAbsent("get_network_stats");
3189
- }
3190
- else {
3191
- const passive = getPassiveConnectionStatus();
3192
- connectionWarning = !passive.connected
3193
- ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
3194
- : "";
3195
- }
3196
- return {
3197
- content: [
3198
- {
3199
- type: "text",
3200
- text: `Network Statistics:\n\n${stats}${connectionWarning}`
3201
- }
3202
- ]
3203
- };
3204
- },
3205
- // Empty result detector: buffer has no entries at all
3206
- () => { let total = 0; for (const b of networkBuffers.values())
3207
- total += b.size; return total === 0; });
3208
- // Tool: Clear network requests
3209
- registerToolWithTelemetry("clear_network", {
3210
- description: "Clear the network request buffer.\n" +
3211
- "PURPOSE: Reset the captured request list to isolate new traffic from a specific user action.\n" +
3212
- "WHEN TO USE: Right before reproducing a bug so the buffer contains only the relevant requests.\n" +
3213
- "WORKFLOW: clear_network -> trigger action (tap, execute_in_app) -> get_network_requests / search_network.\n" +
3214
- "LIMITATIONS: Irreversible — cleared requests cannot be recovered. Also clears the SDK's in-app buffer when SDK is present.\n" +
3215
- "GOOD: clear_network() before a reproduction.\n" +
3216
- "BAD: Using clear_network as a workaround for stale connections — use scan_metro / ensure_connection instead.\n" +
3217
- "SEE ALSO: call get_usage_guide(topic=\"network\") for the full network-inspect playbook.",
3218
- inputSchema: {
3219
- device: z.string().optional().describe("Target device name (substring match). Omit to clear all devices. Run get_apps to see connected devices.")
3220
- }
3221
- }, async ({ device }) => {
3222
- let totalCleared = 0;
3223
- if (device) {
3224
- const app = getConnectedAppByDevice(device);
3225
- if (!app)
3226
- throw new UserInputError(`No connected device matches "${device}"`);
3227
- const deviceName = app.deviceInfo.deviceName || app.deviceInfo.title || "unknown";
3228
- totalCleared = getNetworkBuffer(deviceName).clear();
3229
- }
3230
- else {
3231
- for (const buffer of networkBuffers.values()) {
3232
- totalCleared += buffer.clear();
3233
- }
3234
- }
3235
- // Also clear SDK buffer if available
3236
- const sdkAvailable = await isSDKInstalled();
3237
- if (sdkAvailable) {
3238
- const sdkResult = await clearSDKNetwork();
3239
- if (sdkResult.success && sdkResult.count) {
3240
- totalCleared += sdkResult.count;
3241
- }
3242
- }
3243
- return {
3244
- content: [
3245
- {
3246
- type: "text",
3247
- text: `Cleared ${totalCleared} network requests from buffer.`
3248
- }
3249
- ]
3250
- };
3251
- });
3252
- // Tool: Get images from shared image buffer
3253
- registerToolWithTelemetry("get_images", {
3254
- 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.\n" +
3255
- "PURPOSE: Retrieve prior screenshots — especially tap burst frames — without re-taking them, for visual diffing or reviewing transient UI states.\n" +
3256
- "WHEN TO USE: After tap(burst=true) reports transientChangeDetected, or to compare before/after frames without another screenshot round-trip.\n" +
3257
- "WORKFLOW: tap(burst=true) -> note verification.burstGroupId -> get_images(groupId, frameIndex=N) to inspect individual frames.\n" +
3258
- "LIMITATIONS: Circular buffer (50 entries) — old images are evicted. Metadata is cheap; fetching image data is not — request specific ids, not bulk.\n" +
3259
- "GOOD: get_images({ list: true }); get_images({ groupId: \"burst-abc\", frameIndex: 2 })\n" +
3260
- "BAD: get_images() with no filter when buffer is full — floods context. Use list:true or last:N first.\n" +
3261
- "SEE ALSO: call get_usage_guide(topic=\"interact\") for the full interaction playbook.",
3262
- inputSchema: {
3263
- list: z.boolean().optional().describe("List all entries and groups (metadata only, no image data)"),
3264
- id: z.string().optional().describe("Retrieve a specific image by ID (returns image data)"),
3265
- groupId: z.string().optional().describe("List frames in a group (metadata only), or combine with frameIndex to retrieve a specific frame"),
3266
- frameIndex: z.coerce.number().optional().describe("Retrieve a specific frame from a group (requires groupId)"),
3267
- last: z.coerce.number().optional().describe("Return the N most recent entries (metadata only)"),
3268
- source: z.string().optional().describe("Filter entries by source"),
3269
- clear: z.boolean().optional().describe("Clear the buffer")
3270
- }
3271
- }, async ({ id, groupId, frameIndex, last, source, clear }) => {
3272
- if (clear) {
3273
- const count = imageBuffer.clear();
3274
- return {
3275
- content: [{ type: "text", text: `Cleared ${count} images from buffer.` }]
3276
- };
3277
- }
3278
- if (id) {
3279
- const entry = imageBuffer.getById(id);
3280
- if (!entry) {
3281
- return {
3282
- content: [{ type: "text", text: `No image found with id "${id}".` }],
3283
- isError: true
3284
- };
3285
- }
3286
- const { image, ...meta } = entry;
3287
- return {
3288
- content: [
3289
- { type: "text", text: JSON.stringify(meta, null, 2) },
3290
- { type: "image", data: image.toString("base64"), mimeType: "image/png" }
3291
- ]
3292
- };
3293
- }
3294
- if (groupId !== undefined && frameIndex !== undefined) {
3295
- const entry = imageBuffer.getByGroupFrame(groupId, frameIndex);
3296
- if (!entry) {
3297
- return {
3298
- content: [{ type: "text", text: `No frame ${frameIndex} found in group "${groupId}".` }],
3299
- isError: true
3300
- };
3301
- }
3302
- const { image, ...meta } = entry;
3303
- return {
3304
- content: [
3305
- { type: "text", text: JSON.stringify(meta, null, 2) },
3306
- { type: "image", data: image.toString("base64"), mimeType: "image/png" }
3307
- ]
3308
- };
3309
- }
3310
- const entries = imageBuffer.listEntries({ source, groupId, last });
3311
- const groups = imageBuffer.listGroups();
3312
- return {
3313
- content: [
3314
- {
3315
- type: "text",
3316
- text: JSON.stringify({ entries, groups, total: imageBuffer.size }, null, 2)
3317
- }
3318
- ]
3319
- };
3320
- });
3321
- // Tool: Reload the app
3322
- registerToolWithTelemetry("reload_app", {
3323
- description: "Reload the React Native app (triggers JavaScript bundle reload like pressing 'r' in Metro).\n" +
3324
- "DO NOT call this reflexively after JS/TS/TSX/style edits — Fast Refresh applies those automatically in 1-2s. Reloading discards in-memory state: navigation stack, context, hooks state, BLE/WebSocket connections, paired devices, auth sessions. That can force re-pairing or re-login and break your verification loop, so the cost of an unnecessary reload is high.\n" +
3325
- "ONLY reload when: (1) native code, app.json, Info.plist, Podfile, or a native module changed; (2) Fast Refresh visibly failed (red-screen, stale render confirmed via screenshot after a few seconds); (3) the app is in a broken/error state; (4) you need to reset app state completely; or (5) the user explicitly asks. If unsure, take a screenshot first and verify Fast Refresh didn't already apply the change — don't reload defensively.\n" +
3326
- "Will auto-connect to Metro if no connection exists. After reload, the app may take a few seconds to fully restart and become responsive — wait before running other tools.",
3327
- inputSchema: {
3328
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
3329
- }
3330
- }, async ({ device }) => {
3331
- const result = await reloadApp(device);
3332
- if (!result.success) {
3333
- return {
3334
- content: [
3335
- {
3336
- type: "text",
3337
- text: `Error: ${result.error}`
3338
- }
3339
- ],
3340
- isError: true
3341
- };
3342
- }
3343
- return {
3344
- content: [
3345
- {
3346
- type: "text",
3347
- text: result.result ?? "App reload triggered"
3348
- }
3349
- ]
3350
- };
3351
- });
3352
- // Tool: LogBox control (replaces dismiss_logbox)
3353
- registerToolWithTelemetry("logbox", {
3354
- description: "Interact with React Native's LogBox overlay (dev mode only). " +
3355
- 'Actions: "dismiss" clears all entries and returns their content. ' +
3356
- '"push" displays a message in the LogBox error banner (visible to the developer watching the device). ' +
3357
- '"ignore" adds patterns to suppress future LogBox entries for this session. ' +
3358
- '"detect" reads current LogBox state without modifying it. ' +
3359
- "Only works in __DEV__ mode — LogBox does not exist in production builds.\n" +
3360
- "PURPOSE: Control RN's on-device red/yellow overlay — clear it when it blocks UI, suppress noisy repeats, or push a message back to the developer.\n" +
3361
- "WHEN TO USE: Screenshot/tap reports LogBox is obstructing the screen, an error banner prevents interaction, or you want to surface info to the dev watching the simulator.\n" +
3362
- "WORKFLOW: logbox(action=\"detect\") -> if present: logbox(action=\"dismiss\") to read + clear -> continue UI work. Use action=\"ignore\" with patterns to stop repeat noise.\n" +
3363
- "LIMITATIONS: Dev-only — no effect in production builds. \"push\" at level=\"warning\" won't show a banner unless LogBox is already open.\n" +
3364
- "GOOD: logbox({ action: \"dismiss\" }); logbox({ action: \"ignore\", patterns: [\"[APOLLO]\"] })\n" +
3365
- "BAD: Spamming logbox(action=\"push\") for every tool step — annoys the developer.\n" +
3366
- "SEE ALSO: call get_usage_guide(topic=\"logs\") for the full logs playbook.",
3367
- inputSchema: {
3368
- action: z.enum(["dismiss", "push", "ignore", "detect"]).describe('Action to perform: "dismiss", "push", "ignore", or "detect"'),
3369
- message: z.string().optional().describe('Message to push into LogBox (required when action="push")'),
3370
- 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'),
3371
- 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)'),
3372
- 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.'),
3373
- 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'),
3374
- patterns: z.array(z.string()).optional().describe('Patterns to ignore (required when action="ignore"), e.g. ["[APOLLO]", "deprecated"]'),
3375
- device: z.string().optional().describe("Target device name (substring match). Omit for default device. Run get_apps to see connected devices.")
3376
- }
3377
- }, async ({ action, message, level, expanded, subtitle, target, patterns, device }) => {
3378
- const explainLogBoxError = (base) => {
3379
- const reason = getLastLogBoxError();
3380
- const reasonHints = {
3381
- dev_false: "__DEV__ is false — either a production build OR a stale/zombie CDP target whose JS context is in a degraded state. Try scan_metro to drop stale connections, then retry.",
3382
- no_get_modules: "__r.getModules is missing — usually a stale CDP target (not a real runtime). Try scan_metro to refresh.",
3383
- modules_not_iterable: "Module registry returned an unexpected shape.",
3384
- logbox_module_not_found: "LogBoxData module not found in the Metro registry — possible if LogBox is tree-shaken or the app overrode it.",
3385
- execute_failed: "executeInApp failed — no connected app or evaluate timed out.",
3386
- exception: "Exception thrown during detection.",
3387
- };
3388
- const detail = reason ? (reasonHints[reason] ?? reason) : "unknown cause";
3389
- return `${base}\nReason: ${reason ?? "unknown"} — ${detail}`;
3390
- };
3391
- if (action === "dismiss") {
3392
- const result = await dismissLogBox(device);
3393
- if (result === null) {
3394
- return {
3395
- content: [
3396
- {
3397
- type: "text",
3398
- text: explainLogBoxError("LogBox not available.")
3399
- }
3400
- ]
3401
- };
3402
- }
3403
- if (result.totalDismissed === 0) {
3404
- return {
3405
- content: [
3406
- {
3407
- type: "text",
3408
- text: "No LogBox entries to dismiss."
3409
- }
3410
- ]
3411
- };
3412
- }
3413
- return {
3414
- content: [
3415
- {
3416
- type: "text",
3417
- text: formatDismissedEntries(result)
3418
- }
3419
- ]
3420
- };
3421
- }
3422
- if (action === "push") {
3423
- if (!message) {
3424
- return {
3425
- content: [
3426
- {
3427
- type: "text",
3428
- text: 'Error: "message" parameter is required when action is "push".'
3429
- }
3430
- ],
3431
- isError: true
3432
- };
3433
- }
3434
- const success = await pushLogBox(message, level || "error", expanded || false, target || "logbox", subtitle, device);
3435
- if (!success) {
3436
- return {
3437
- content: [
3438
- {
3439
- type: "text",
3440
- text: explainLogBoxError("Failed to push message to LogBox.")
3441
- }
3442
- ]
3443
- };
3444
- }
3445
- return {
3446
- content: [
3447
- {
3448
- type: "text",
3449
- text: target === "metro"
3450
- ? `Message pushed to Metro terminal: "${message}"`
3451
- : `Message pushed to LogBox as ${level || "error"}: "${message}"`
3452
- }
3453
- ]
3454
- };
3455
- }
3456
- if (action === "ignore") {
3457
- if (!patterns || patterns.length === 0) {
3458
- return {
3459
- content: [
3460
- {
3461
- type: "text",
3462
- text: 'Error: "patterns" parameter is required when action is "ignore" (non-empty array of strings).'
3463
- }
3464
- ],
3465
- isError: true
3466
- };
3467
- }
3468
- const activePatterns = await addLogBoxIgnorePatterns(patterns, device);
3469
- if (activePatterns === null) {
3470
- return {
3471
- content: [
3472
- {
3473
- type: "text",
3474
- text: explainLogBoxError("Failed to add ignore patterns.")
3475
- }
3476
- ]
3477
- };
3478
- }
3479
- return {
3480
- content: [
3481
- {
3482
- type: "text",
3483
- text: `Added ignore patterns: ${patterns.map((p) => `"${p}"`).join(", ")}\n\nAll active ignore patterns: ${activePatterns.map((p) => `"${p}"`).join(", ")}`
3484
- }
3485
- ]
3486
- };
3487
- }
3488
- if (action === "detect") {
3489
- const state = await detectLogBox(device);
3490
- if (state === null) {
3491
- return {
3492
- content: [
3493
- {
3494
- type: "text",
3495
- text: explainLogBoxError("LogBox not available.")
3496
- }
3497
- ]
3498
- };
3499
- }
3500
- if (state.total === 0) {
3501
- return {
3502
- content: [
3503
- {
3504
- type: "text",
3505
- text: "No LogBox entries detected."
3506
- }
3507
- ]
3508
- };
3509
- }
3510
- const MAX_MSG_LENGTH = 150;
3511
- 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`;
3512
- for (const entry of state.entries) {
3513
- const truncated = entry.message.length > MAX_MSG_LENGTH ? entry.message.substring(0, MAX_MSG_LENGTH) + "..." : entry.message;
3514
- const countStr = entry.count > 1 ? ` (x${entry.count})` : "";
3515
- output += `\n[${entry.level}] ${truncated}${countStr}`;
3516
- }
3517
- return {
3518
- content: [
3519
- {
3520
- type: "text",
3521
- text: output
3522
- }
3523
- ]
3524
- };
3525
- }
3526
- return {
3527
- content: [
3528
- {
3529
- type: "text",
3530
- text: `Unknown action: "${action}". Use "dismiss", "push", "ignore", or "detect".`
3531
- }
3532
- ],
3533
- isError: true
3534
- };
3535
- });
3536
- // ============================================================================
3537
- // Bundle/Build Error Tools
3538
- // ============================================================================
3539
- // Tool: Get bundle status
3540
- registerToolWithTelemetry("get_bundle_status", {
3541
- description: "Get the current Metro bundler status including build state and any recent bundling errors. Use this to check if there are compilation/bundling errors that prevent the app from loading.\n" +
3542
- "PURPOSE: Snapshot Metro's current build state (idle / transforming / error) together with any captured errors — a fast \"is the bundler healthy?\" check.\n" +
3543
- "WHEN TO USE: Before diving into runtime debugging — rules out compile-time failures that would make get_logs and tap pointless.\n" +
3544
- "WORKFLOW: get_bundle_status -> if errors present: get_bundle_errors for detail -> fix -> clear_bundle_errors.\n" +
3545
- "LIMITATIONS: Relies on Metro's WebSocket event stream; if Metro isn't running or the connection dropped, status may be stale.\n" +
3546
- "GOOD: get_bundle_status() at the start of a debug session.\n" +
3547
- "BAD: Polling every second — Metro events are push-based; just call once and act on the result.\n" +
3548
- "SEE ALSO: call get_usage_guide(topic=\"bundle\") for the full bundle-check playbook.",
3549
- inputSchema: {}
3550
- }, async () => {
3551
- // Get port from first connected app if available
3552
- const apps = Array.from(connectedApps.values());
3553
- const metroPort = apps.length > 0 ? apps[0].port : undefined;
3554
- const { formatted } = await getBundleStatusWithErrors(bundleErrorBuffer, metroPort);
3555
- return {
3556
- content: [
3557
- {
3558
- type: "text",
3559
- text: formatted
3560
- }
3561
- ]
3562
- };
3563
- });
3564
- // Tool: Get bundle errors
3565
- registerToolWithTelemetry("get_bundle_errors", {
3566
- description: "Retrieve captured Metro bundling/compilation errors. These are errors that occur during the bundle build process (import resolution, syntax errors, transform errors) that prevent the app from loading. If no errors are captured but Metro is running without connected apps, automatically falls back to screenshot+OCR to capture the error from the device screen.\n" +
3567
- "PURPOSE: Surface Metro's build-time failures (not runtime JS errors) that keep the app from booting or hot-reloading.\n" +
3568
- "WHEN TO USE: App shows the red error screen, refuses to connect, or Fast Refresh stops working after an edit. Also use when get_logs is silent but the app is clearly broken.\n" +
3569
- "WORKFLOW: get_bundle_status -> get_bundle_errors -> fix source -> clear_bundle_errors -> reload_app.\n" +
3570
- "LIMITATIONS: Captures errors Metro emits via its WebSocket; the screenshot+OCR fallback requires a booted simulator and the platform param.\n" +
3571
- "GOOD: get_bundle_errors({ platform: \"ios\" })\n" +
3572
- "BAD: Using get_bundle_errors to look for runtime TypeErrors — those live in get_logs, not the bundler.\n" +
3573
- "SEE ALSO: call get_usage_guide(topic=\"bundle\") for the full bundle-check playbook.",
3574
- inputSchema: {
3575
- maxErrors: z.number().optional().default(10).describe("Maximum number of errors to return (default: 10)"),
3576
- platform: z
3577
- .enum(["ios", "android"])
3578
- .optional()
3579
- .describe("Platform for screenshot fallback when no errors are captured via CDP. Required to enable fallback."),
3580
- deviceId: z
3581
- .string()
3582
- .optional()
3583
- .describe("Optional device ID for screenshot fallback. Uses first available device if not specified.")
3584
- }
3585
- }, async ({ maxErrors, platform, deviceId }) => {
3586
- // First, try to get errors from the buffer (captured via CDP/Metro WebSocket)
3587
- const { errors, formatted } = getBundleErrors(bundleErrorBuffer, { maxErrors });
3588
- if (errors.length > 0) {
3589
- return {
3590
- content: [
3591
- {
3592
- type: "text",
3593
- text: `Bundle Errors (${errors.length} captured):\n\n${formatted}`
3594
- }
3595
- ]
3596
- };
3597
- }
3598
- // No errors in buffer - check if we should try fallback
3599
- if (!platform) {
3600
- // No platform specified, return empty result with hint
3601
- return {
3602
- content: [
3603
- {
3604
- type: "text",
3605
- text: `Bundle Errors (0 captured):\n\nNo bundle errors captured.\n\nTip: If the app failed to load and you see a red error screen on the device, use the 'platform' parameter (ios/android) to enable screenshot+OCR fallback for error capture.`
3606
- }
3607
- ]
3608
- };
3609
- }
3610
- // Check Metro state to see if fallback is warranted
3611
- const metroState = await checkMetroState(connectedApps.size);
3612
- if (!metroState.needsFallback) {
3613
- // Metro not running or apps are connected - fallback not needed
3614
- const statusMsg = metroState.metroRunning
3615
- ? "Metro is running and apps are connected."
3616
- : "Metro is not running.";
3617
- return {
3618
- content: [
3619
- {
3620
- type: "text",
3621
- text: `Bundle Errors (0 captured):\n\nNo bundle errors captured. ${statusMsg}`
3622
- }
3623
- ]
3624
- };
3625
- }
3626
- // Metro is running but no apps connected - try screenshot fallback
3627
- try {
3628
- let screenshotResult;
3629
- if (platform === "android") {
3630
- screenshotResult = await androidScreenshot(undefined, deviceId);
3631
- }
3632
- else {
3633
- screenshotResult = await iosScreenshot(undefined, deviceId);
3634
- }
3635
- if (!screenshotResult.success || !screenshotResult.data) {
3636
- return {
3637
- content: [
3638
- {
3639
- type: "text",
3640
- text: `Bundle Errors (0 captured):\n\nNo bundle errors captured via CDP.\n\nMetro is running on port(s) ${metroState.metroPorts.join(", ")} but no apps are connected (possible bundle error).\n\nScreenshot fallback failed: ${screenshotResult.error || "No image data"}`
3641
- }
3642
- ]
3643
- };
3644
- }
3645
- // Calculate device pixel ratio for iOS
3646
- const devicePixelRatio = platform === "ios" && screenshotResult.originalWidth && screenshotResult.originalHeight
3647
- ? inferIOSDevicePixelRatio(screenshotResult.originalWidth, screenshotResult.originalHeight)
3648
- : 1;
3649
- // Run OCR on the screenshot
3650
- const ocrResult = await recognizeText(screenshotResult.data, {
3651
- scaleFactor: screenshotResult.scaleFactor || 1,
3652
- platform,
3653
- devicePixelRatio
3654
- });
3655
- if (!ocrResult.success || !ocrResult.fullText) {
3656
- return {
3657
- content: [
3658
- {
3659
- type: "text",
3660
- text: `Bundle Errors (0 captured):\n\nNo bundle errors captured via CDP.\n\nMetro is running on port(s) ${metroState.metroPorts.join(", ")} but no apps are connected.\n\nScreenshot captured but OCR found no text. The screen may not show an error message.`
3661
- }
3662
- ]
3663
- };
3664
- }
3665
- // Parse the OCR text for error information
3666
- const parsedError = parseErrorScreenText(ocrResult.fullText);
3667
- if (!parsedError.found || !parsedError.error) {
3668
- return {
3669
- content: [
3670
- {
3671
- type: "text",
3672
- text: `Bundle Errors (0 captured):\n\nNo bundle errors captured via CDP.\n\nMetro is running on port(s) ${metroState.metroPorts.join(", ")} but no apps are connected.\n\nScreenshot OCR text:\n${ocrResult.fullText.substring(0, 1000)}${ocrResult.fullText.length > 1000 ? "..." : ""}\n\n(No error pattern detected in text)`
3673
- }
3674
- ]
3675
- };
3676
- }
3677
- // Add the parsed error to the buffer for future reference
3678
- bundleErrorBuffer.add(parsedError.error);
3679
- return {
3680
- content: [
3681
- {
3682
- type: "text",
3683
- text: `Bundle Errors (1 captured via screenshot fallback):\n\n${formatParsedError(parsedError)}`
3684
- }
3685
- ]
3686
- };
3687
- }
3688
- catch (fallbackError) {
3689
- return {
3690
- content: [
3691
- {
3692
- type: "text",
3693
- text: `Bundle Errors (0 captured):\n\nNo bundle errors captured via CDP.\n\nMetro is running on port(s) ${metroState.metroPorts.join(", ")} but no apps are connected.\n\nScreenshot fallback error: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`
3694
- }
3695
- ]
3696
- };
3697
- }
3698
- });
3699
- // Tool: Clear bundle errors
3700
- registerToolWithTelemetry("clear_bundle_errors", {
3701
- description: "Clear the bundle error buffer.\n" +
3702
- "PURPOSE: Reset captured Metro build errors after a fix so get_bundle_status/get_bundle_errors reflect the current state.\n" +
3703
- "WHEN TO USE: After fixing a bundling error and before re-triggering Metro to confirm the error is gone.\n" +
3704
- "SEE ALSO: call get_usage_guide(topic=\"bundle\") for the full bundle-check playbook.",
3705
- inputSchema: {}
3706
- }, async () => {
3707
- const count = bundleErrorBuffer.clear();
3708
- return {
3709
- content: [
3710
- {
3711
- type: "text",
3712
- text: `Cleared ${count} bundle errors from buffer.`
3713
- }
3714
- ]
3715
- };
3716
- });
3717
- // ============================================================================
3718
- // Android Tools
3719
- // ============================================================================
3720
- // Tool: List Android devices
3721
- registerToolWithTelemetry("list_android_devices", {
3722
- description: "List connected Android devices and emulators via ADB.\n" +
3723
- "PURPOSE: Discover which physical devices and emulators are visible to adb so you can pick a target UDID/serial.\n" +
3724
- "WHEN TO USE: Before android_install_app / android_launch_app, or when a tool reports \"no device\" and you need to confirm visibility.\n" +
3725
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
3726
- inputSchema: {}
3727
- }, async () => {
3728
- const result = await listAndroidDevices();
3729
- return {
3730
- content: [
3731
- {
3732
- type: "text",
3733
- text: result.success ? result.result : `Error: ${result.error}`
3734
- }
3735
- ],
3736
- isError: !result.success
3737
- };
3738
- });
3739
- // Tool: Android install app
3740
- registerToolWithTelemetry("android_install_app", {
3741
- description: "Install an APK on an Android device/emulator" +
3742
- platformUniqueBanner("installing an Android APK") +
3743
- "\nPURPOSE: Push a built APK to a connected Android device or emulator via adb." +
3744
- "\nWHEN TO USE: After producing a fresh build, when switching app variants, or when preparing a clean test run." +
3745
- "\nSEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
3746
- inputSchema: {
3747
- apkPath: z.string().describe("Path to the APK file to install"),
3748
- deviceId: z
3749
- .string()
3750
- .optional()
3751
- .describe("Optional device ID. Uses first available device if not specified."),
3752
- replace: z
3753
- .boolean()
3754
- .optional()
3755
- .default(true)
3756
- .describe("Replace existing app if already installed (default: true)"),
3757
- grantPermissions: z
3758
- .boolean()
3759
- .optional()
3760
- .default(false)
3761
- .describe("Grant all runtime permissions on install (default: false)")
3762
- }
3763
- }, async ({ apkPath, deviceId, replace, grantPermissions }) => {
3764
- const result = await androidInstallApp(apkPath, deviceId, { replace, grantPermissions });
3765
- return {
3766
- content: [
3767
- {
3768
- type: "text",
3769
- text: result.success ? result.result : `Error: ${result.error}`
3770
- }
3771
- ],
3772
- isError: !result.success
3773
- };
3774
- });
3775
- // Tool: Android launch app
3776
- registerToolWithTelemetry("android_launch_app", {
3777
- description: "Launch an app on an Android device/emulator by package name" +
3778
- platformUniqueBanner("launching an Android app by package name") +
3779
- "\nPURPOSE: Start an installed Android app by its package (and optional activity) so the next tool calls hit a running process." +
3780
- "\nWHEN TO USE: After android_install_app, after a force-stop, or when the app isn't foregrounded before interaction." +
3781
- "\nSEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
3782
- inputSchema: {
3783
- packageName: z.string().describe("Package name of the app (e.g., com.example.myapp)"),
3784
- activityName: z
3785
- .string()
3786
- .optional()
3787
- .describe("Optional activity name to launch (e.g., .MainActivity). If not provided, launches the main activity."),
3788
- deviceId: z
3789
- .string()
3790
- .optional()
3791
- .describe("Optional device ID. Uses first available device if not specified.")
3792
- }
3793
- }, async ({ packageName, activityName, deviceId }) => {
3794
- const result = await androidLaunchApp(packageName, activityName, deviceId);
3795
- return {
3796
- content: [
3797
- {
3798
- type: "text",
3799
- text: result.success ? result.result : `Error: ${result.error}`
3800
- }
3801
- ],
3802
- isError: !result.success
3803
- };
3804
- });
3805
- // Tool: Android list packages
3806
- registerToolWithTelemetry("android_list_packages", {
3807
- description: "List installed packages on an Android device/emulator" +
3808
- platformUniqueBanner("listing installed Android packages") +
3809
- "\nPURPOSE: Enumerate package names visible to adb so you can confirm installation or pick the right target for android_launch_app." +
3810
- "\nWHEN TO USE: Before android_launch_app when you don't know the exact package name, or to verify an install succeeded." +
3811
- "\nSEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
3812
- inputSchema: {
3813
- deviceId: z
3814
- .string()
3815
- .optional()
3816
- .describe("Optional device ID. Uses first available device if not specified."),
3817
- filter: z.string().optional().describe("Optional filter to search packages by name (case-insensitive)")
3818
- }
3819
- }, async ({ deviceId, filter }) => {
3820
- const result = await androidListPackages(deviceId, filter);
3821
- return {
3822
- content: [
3823
- {
3824
- type: "text",
3825
- text: result.success ? result.result : `Error: ${result.error}`
3826
- }
3827
- ],
3828
- isError: !result.success
3829
- };
3830
- });
3831
- // ============================================================================
3832
- // Android UI Input Tools (Phase 2)
3833
- // ============================================================================
3834
- // Tool: Android long press
3835
- registerToolWithTelemetry("android_long_press", {
3836
- description: "Long press at specific coordinates on an Android device/emulator screen" +
3837
- platformFallbackBanner("`tap` for short taps; keep android_long_press for long-press gestures specifically") +
3838
- "\nPURPOSE: Emit a sustained touch at raw pixel coordinates to trigger long-press handlers (context menus, drag starts, multi-select)." +
3839
- "\nWHEN TO USE: Only when a long-press gesture is required — regular taps should go through `tap`." +
3840
- "\nSEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
3841
- inputSchema: {
3842
- x: z.coerce.number().describe("X coordinate in pixels"),
3843
- y: z.coerce.number().describe("Y coordinate in pixels"),
3844
- durationMs: z.number().optional().default(1000).describe("Press duration in milliseconds (default: 1000)"),
3845
- deviceId: z
3846
- .string()
3847
- .optional()
3848
- .describe("Optional device ID. Uses first available device if not specified.")
3849
- }
3850
- }, async ({ x, y, durationMs, deviceId }) => {
3851
- const result = await androidLongPress(x, y, durationMs, deviceId);
3852
- return {
3853
- content: [
3854
- {
3855
- type: "text",
3856
- text: result.success ? result.result : `Error: ${result.error}`
3857
- }
3858
- ],
3859
- isError: !result.success
3860
- };
3861
- });
3862
- // Tool: Android swipe
3863
- registerToolWithTelemetry("android_swipe", {
3864
- description: "Swipe from one point to another on an Android device/emulator screen" +
3865
- platformFallbackBanner("`tap` for targeted interactions; keep android_swipe for raw-coordinate gestures") +
3866
- "\nPURPOSE: Perform a raw-coordinate swipe gesture for scrolling, paging, dismissing sheets, or drawer opens on Android." +
3867
- "\nWHEN TO USE: When you need a gesture rather than a tap — scroll lists, swipe carousels, or pull-to-refresh." +
3868
- "\nSEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
3869
- inputSchema: {
3870
- startX: z.coerce.number().describe("Starting X coordinate in pixels"),
3871
- startY: z.coerce.number().describe("Starting Y coordinate in pixels"),
3872
- endX: z.coerce.number().describe("Ending X coordinate in pixels"),
3873
- endY: z.coerce.number().describe("Ending Y coordinate in pixels"),
3874
- durationMs: z.number().optional().default(300).describe("Swipe duration in milliseconds (default: 300)"),
3875
- deviceId: z
3876
- .string()
3877
- .optional()
3878
- .describe("Optional device ID. Uses first available device if not specified.")
3879
- }
3880
- }, async ({ startX, startY, endX, endY, durationMs, deviceId }) => {
3881
- const result = await androidSwipe(startX, startY, endX, endY, durationMs, deviceId);
3882
- return {
3883
- content: [
3884
- {
3885
- type: "text",
3886
- text: result.success ? result.result : `Error: ${result.error}`
3887
- }
3888
- ],
3889
- isError: !result.success
3890
- };
3891
- });
3892
- // Tool: Android input text
3893
- registerToolWithTelemetry("android_input_text", {
3894
- description: "Type text on an Android device/emulator." +
3895
- platformFallbackBanner("`tap(text=...)` — it auto-focuses TextInput via the fiber tree") +
3896
- " The text will be input at the current focus point (tap an input field first)." +
3897
- "\nPURPOSE: Send keystrokes to whichever input currently has focus on Android — the tool does NOT focus a field itself." +
3898
- "\nWHEN TO USE: Only after an input is already focused, or when `tap(text=...)` on the input didn't take focus for some reason." +
3899
- "\nREPLACE MODE: pass replace:true to clear the focused field first (via React onChangeText so controlled state stays consistent), then type the new value. Use for pre-filled fields where appending would corrupt the value." +
3900
- "\nSEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
3901
- inputSchema: {
3902
- text: z.string().describe("Text to type"),
3903
- replace: z
3904
- .boolean()
3905
- .optional()
3906
- .describe("If true, clear the focused TextInput via React onChangeText before typing. Use to set a pre-filled field to an exact value without concatenation. Requires Bridgeless/Fabric."),
3907
- device: z
3908
- .string()
3909
- .optional()
3910
- .describe("Optional RN device name (substring match) — needed by replace:true when multiple RN apps are connected, to disambiguate which device's focused input to clear. Single-device sessions can omit."),
3911
- deviceId: z
3912
- .string()
3913
- .optional()
3914
- .describe("Optional device ID. Uses first available device if not specified.")
3915
- }
3916
- }, async ({ text, replace, device, deviceId }) => {
3917
- const result = await inputTextWithReplace(text, replace === true, (t) => androidInputText(t, deviceId), () => clearFocusedInput(device));
3918
- return {
3919
- content: [
3920
- {
3921
- type: "text",
3922
- text: result.success ? result.result : `Error: ${result.error}`
3923
- }
3924
- ],
3925
- isError: !result.success
3926
- };
3927
- });
3928
- // Tool: Android key event
3929
- registerToolWithTelemetry("android_key_event", {
3930
- description: "Send a key event to an Android device/emulator." +
3931
- platformUniqueBanner("sending Android key events (BACK, HOME, MENU, etc.)") +
3932
- ` Common keys: ${Object.keys(ANDROID_KEY_EVENTS).join(", ")}` +
3933
- "\nPURPOSE: Dispatch Android system keys (BACK, HOME, MENU, ENTER, DEL, etc.) that aren't reachable via on-screen tap." +
3934
- "\nWHEN TO USE: Navigate back from a screen, submit a form with ENTER, dismiss the keyboard, or press hardware-style keys during a flow." +
3935
- "\nSEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
3936
- inputSchema: {
3937
- key: z.string().describe(`Key name (${Object.keys(ANDROID_KEY_EVENTS).join(", ")}) or numeric keycode`),
3938
- deviceId: z
3939
- .string()
3940
- .optional()
3941
- .describe("Optional device ID. Uses first available device if not specified.")
3942
- }
3943
- }, async ({ key, deviceId }) => {
3944
- // Try to parse as number first, otherwise treat as key name
3945
- const keyCode = /^\d+$/.test(key) ? parseInt(key, 10) : key.toUpperCase();
3946
- const result = await androidKeyEvent(keyCode, deviceId);
3947
- return {
3948
- content: [
3949
- {
3950
- type: "text",
3951
- text: result.success ? result.result : `Error: ${result.error}`
3952
- }
3953
- ],
3954
- isError: !result.success
3955
- };
3956
- });
3957
- // Tool: Android get screen size
3958
- registerToolWithTelemetry("android_get_screen_size", {
3959
- description: "Get the screen size (resolution) of an Android device/emulator" +
3960
- platformUniqueBanner("reading Android device pixel resolution") +
3961
- "\nPURPOSE: Return the device's pixel width and height so you can compute safe tap/swipe coordinates." +
3962
- "\nWHEN TO USE: Before scripting raw-coordinate gestures on an unfamiliar device, or when normalizing coordinates across devices." +
3963
- "\nSEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
3964
- inputSchema: {
3965
- deviceId: z
3966
- .string()
3967
- .optional()
3968
- .describe("Optional device ID. Uses first available device if not specified.")
3969
- }
3970
- }, async ({ deviceId }) => {
3971
- const result = await androidGetScreenSize(deviceId);
3972
- if (!result.success) {
3973
- return {
3974
- content: [
3975
- {
3976
- type: "text",
3977
- text: `Error: ${result.error}`
3978
- }
3979
- ],
3980
- isError: true
3981
- };
3982
- }
3983
- return {
3984
- content: [
3985
- {
3986
- type: "text",
3987
- text: `Screen size: ${result.width}x${result.height} pixels`
3988
- }
3989
- ]
3990
- };
3991
- });
3992
- // ============================================================================
3993
- // Android Accessibility Tools (UI Hierarchy)
3994
- // ============================================================================
3995
- // ============================================================================
3996
- // iOS Simulator Tools
3997
- // ============================================================================
3998
- // Tool: List iOS simulators
3999
- registerToolWithTelemetry("list_ios_simulators", {
4000
- description: "List available iOS simulators.\n" +
4001
- "PURPOSE: Enumerate installed iOS simulators with their UDIDs and boot state so you can boot or install into the right one.\n" +
4002
- "WHEN TO USE: Before ios_boot_simulator / ios_install_app, or when you need a UDID for a specific device name.\n" +
4003
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
4004
- inputSchema: {
4005
- onlyBooted: z
4006
- .boolean()
4007
- .optional()
4008
- .default(false)
4009
- .describe("Only show currently running simulators (default: false)")
4010
- }
4011
- }, async ({ onlyBooted }) => {
4012
- const result = await listIOSSimulators(onlyBooted);
4013
- return {
4014
- content: [
4015
- {
4016
- type: "text",
4017
- text: result.success ? result.result : `Error: ${result.error}`
4018
- }
4019
- ],
4020
- isError: !result.success
4021
- };
4022
- });
4023
- // Tool: OCR Screenshot - Extract text with coordinates from screenshot
4024
- registerToolWithTelemetry("ocr_screenshot", {
4025
- description: "RECOMMENDED: Use this tool FIRST when you need to find and tap UI elements. Takes a screenshot and extracts all visible text with tap-ready coordinates using OCR. " +
4026
- "ADVANTAGES over accessibility trees: (1) Works on ANY visible text regardless of accessibility labels, (2) Returns ready-to-use tapX/tapY coordinates - no conversion needed, (3) Faster than parsing accessibility hierarchies, (4) Works consistently across iOS and Android. " +
4027
- "USE THIS FOR: Finding buttons, labels, menu items, tab bars, or any text you need to tap. Simply find the text in the results and use its tapX/tapY with the tap command.\n" +
4028
- "PURPOSE: Visually locate text on screen and return coordinates safe to pass straight into tap.\n" +
4029
- "WHEN TO USE: Non-RN surfaces, third-party WebViews, accessibility-poor screens, or when fiber/testID strategies have failed.\n" +
4030
- "WORKFLOW: ocr_screenshot(platform=\"ios\") -> scan results for the label -> tap(x=tapX, y=tapY) -> ios_screenshot to verify.\n" +
4031
- "LIMITATIONS: OCR accuracy degrades on very small or stylized text; icons with no label won't appear — use tap(component=...) instead.\n" +
4032
- "GOOD: ocr_screenshot({ platform: \"ios\" })\n" +
4033
- "BAD: ocr_screenshot used just to view the screen — plain ios_screenshot / android_screenshot is cheaper when you don't need OCR text.\n" +
4034
- "SEE ALSO: call get_usage_guide(topic=\"interact\") for the full device-interaction playbook.",
4035
- inputSchema: {
4036
- platform: z.enum(["ios", "android"]).describe("Platform to capture screenshot from"),
4037
- deviceId: z
4038
- .string()
4039
- .optional()
4040
- .describe("Optional device ID (Android) or UDID (iOS). Uses first available device if not specified.")
4041
- }
4042
- }, async ({ platform, deviceId }) => {
4043
- try {
4044
- // Take screenshot
4045
- const screenshotResult = platform === "android"
4046
- ? await androidScreenshot(undefined, deviceId)
4047
- : await iosScreenshot(undefined, deviceId);
4048
- if (!screenshotResult.success || !screenshotResult.data) {
4049
- return {
4050
- content: [
4051
- {
4052
- type: "text",
4053
- text: `Screenshot failed: ${screenshotResult.error || "No image data"}`
4054
- }
4055
- ],
4056
- isError: true
4057
- };
4058
- }
4059
- // Calculate device pixel ratio for iOS
4060
- const devicePixelRatio = platform === "ios" && screenshotResult.originalWidth && screenshotResult.originalHeight
4061
- ? inferIOSDevicePixelRatio(screenshotResult.originalWidth, screenshotResult.originalHeight)
4062
- : 1;
4063
- // Run OCR on the screenshot
4064
- const scaleFactor = screenshotResult.scaleFactor || 1;
4065
- const ocrResult = await recognizeText(screenshotResult.data, {
4066
- scaleFactor,
4067
- platform,
4068
- devicePixelRatio
4069
- });
4070
- if (!ocrResult.success) {
4071
- return {
4072
- content: [
4073
- {
4074
- type: "text",
4075
- text: `OCR failed: no text recognized`
4076
- }
4077
- ],
4078
- isError: true
4079
- };
4080
- }
4081
- // Format results for MCP tool output
4082
- const elements = ocrResult.words
4083
- .filter((w) => w.confidence > 50 && w.text.trim().length > 0)
4084
- .map((w) => ({
4085
- text: w.text,
4086
- confidence: Math.round(w.confidence),
4087
- tapX: w.tapCenter.x,
4088
- tapY: w.tapCenter.y
4089
- }));
4090
- const result = {
4091
- platform,
4092
- engine: ocrResult.engine || "unknown",
4093
- processingTimeMs: ocrResult.processingTimeMs,
4094
- fullText: ocrResult.fullText?.trim() || "",
4095
- confidence: Math.round(ocrResult.confidence || 0),
4096
- elementCount: elements.length,
4097
- elements,
4098
- note: "tapX/tapY are in device pixels — pass directly to tap(x, y) for automatic platform conversion"
4099
- };
4100
- // Check for LogBox overlay (uses default CDP device — native deviceId cannot be mapped to CDP device name)
4101
- try {
4102
- const logBoxState = await detectLogBox();
4103
- if (logBoxState && logBoxState.total > 0) {
4104
- result.logBoxWarning = formatLogBoxWarning(logBoxState).trim();
4105
- }
4106
- }
4107
- catch {
4108
- // Non-fatal: LogBox detection failure should not break OCR
4109
- }
4110
- // Store screenshot in image buffer
4111
- try {
4112
- imageBuffer.add({
4113
- id: `ocr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
4114
- image: screenshotResult.data,
4115
- timestamp: Date.now(),
4116
- source: "ocr_screenshot",
4117
- metadata: {
4118
- width: screenshotResult.originalWidth || 0,
4119
- height: screenshotResult.originalHeight || 0,
4120
- scaleFactor,
4121
- platform,
4122
- },
4123
- });
4124
- }
4125
- catch {
4126
- // Non-fatal: image buffer write failure should not break OCR response
4127
- }
4128
- return {
4129
- content: [
4130
- {
4131
- type: "text",
4132
- text: JSON.stringify(result, null, 2)
4133
- }
4134
- ]
4135
- };
4136
- }
4137
- catch (error) {
4138
- return {
4139
- content: [
4140
- {
4141
- type: "text",
4142
- text: `OCR failed: ${error instanceof Error ? error.message : String(error)}`
4143
- }
4144
- ],
4145
- isError: true
4146
- };
4147
- }
4148
- });
4149
- // Tool: iOS install app
4150
- registerToolWithTelemetry("ios_install_app", {
4151
- description: "Install an app bundle (.app) on an iOS simulator" +
4152
- platformUniqueBanner("installing an iOS .app/.ipa bundle") +
4153
- "\nPURPOSE: Deploy a built .app bundle onto a booted iOS simulator via simctl." +
4154
- "\nWHEN TO USE: After producing a fresh simulator build, when switching app variants, or when preparing a clean test run." +
4155
- "\nSEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
4156
- inputSchema: {
4157
- appPath: z.string().describe("Path to the .app bundle to install"),
4158
- udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
4159
- }
4160
- }, async ({ appPath, udid }) => {
4161
- const result = await iosInstallApp(appPath, udid);
4162
- return {
4163
- content: [
4164
- {
4165
- type: "text",
4166
- text: result.success ? result.result : `Error: ${result.error}`
4167
- }
4168
- ],
4169
- isError: !result.success
4170
- };
4171
- });
4172
- // Tool: iOS launch app
4173
- registerToolWithTelemetry("ios_launch_app", {
4174
- description: "Launch an app on an iOS simulator by bundle ID" +
4175
- platformUniqueBanner("launching an iOS app by bundle ID") +
4176
- "\nPURPOSE: Start an installed iOS app by its bundle ID so the next tool calls hit a running process." +
4177
- "\nWHEN TO USE: After ios_install_app, after ios_terminate_app, or when the app isn't foregrounded before interaction." +
4178
- "\nSEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
4179
- inputSchema: {
4180
- bundleId: z.string().describe("Bundle ID of the app (e.g., com.example.myapp)"),
4181
- udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
4182
- }
4183
- }, async ({ bundleId, udid }) => {
4184
- const result = await iosLaunchApp(bundleId, udid);
4185
- return {
4186
- content: [
4187
- {
4188
- type: "text",
4189
- text: result.success ? result.result : `Error: ${result.error}`
4190
- }
4191
- ],
4192
- isError: !result.success
4193
- };
4194
- });
4195
- // Tool: iOS open URL
4196
- registerToolWithTelemetry("ios_open_url", {
4197
- description: "Open a URL in the iOS simulator (opens in default handler or Safari).\n" +
4198
- "PURPOSE: Drive an iOS simulator into a deep link or universal link entry point so you can exercise routing from an external entry.\n" +
4199
- "WHEN TO USE: Testing deep-link handlers, universal link routing, OAuth/SSO callback URLs, or any flow that enters the app via a URL.\n" +
4200
- "WORKFLOW: ios_boot_simulator -> ios_launch_app (or have the app running) -> ios_open_url -> ios_screenshot / get_screen_layout to verify the target screen rendered.\n" +
4201
- "GOOD: ios_open_url(url=\"myapp://product/42\") to land directly on a product screen.\n" +
4202
- "BAD: ios_open_url(url=\"...\") used as a substitute for in-app navigation when the user would normally tap — prefer `tap` for normal interaction flows.\n" +
4203
- "SEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook." +
4204
- platformUniqueBanner("testing iOS deep links or universal links"),
4205
- inputSchema: {
4206
- url: z.string().describe("URL to open (e.g., https://example.com or myapp://path)"),
4207
- udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
4208
- }
4209
- }, async ({ url, udid }) => {
4210
- const result = await iosOpenUrl(url, udid);
4211
- return {
4212
- content: [
4213
- {
4214
- type: "text",
4215
- text: result.success ? result.result : `Error: ${result.error}`
4216
- }
4217
- ],
4218
- isError: !result.success
4219
- };
4220
- });
4221
- // Tool: iOS terminate app
4222
- registerToolWithTelemetry("ios_terminate_app", {
4223
- description: "Terminate a running app on an iOS simulator" +
4224
- platformUniqueBanner("force-terminating an iOS app") +
4225
- "\nPURPOSE: Force-kill an iOS app process so the next launch starts from a cold state." +
4226
- "\nWHEN TO USE: To reset app state fully (beyond what reload_app does), or before reinstalling a new build." +
4227
- "\nSEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook.",
4228
- inputSchema: {
4229
- bundleId: z.string().describe("Bundle ID of the app to terminate"),
4230
- udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
4231
- }
4232
- }, async ({ bundleId, udid }) => {
4233
- const result = await iosTerminateApp(bundleId, udid);
4234
- return {
4235
- content: [
4236
- {
4237
- type: "text",
4238
- text: result.success ? result.result : `Error: ${result.error}`
4239
- }
4240
- ],
4241
- isError: !result.success
4242
- };
4243
- });
4244
- // Tool: iOS boot simulator
4245
- registerToolWithTelemetry("ios_boot_simulator", {
4246
- description: "Boot an iOS simulator by UDID.\n" +
4247
- "PURPOSE: Bring a specific simulator online so you can install/launch an app in it.\n" +
4248
- "WHEN TO USE: At session start when no simulator is running, or after switching between device models.\n" +
4249
- "SEE ALSO: call get_usage_guide(topic=\"setup\") for the full session-setup playbook." +
4250
- platformUniqueBanner("booting an iOS simulator") +
4251
- " Use list_ios_simulators to find available simulators.",
4252
- inputSchema: {
4253
- udid: z.string().describe("UDID of the simulator to boot (from list_ios_simulators)")
4254
- }
4255
- }, async ({ udid }) => {
4256
- const result = await iosBootSimulator(udid);
4257
- return {
4258
- content: [
4259
- {
4260
- type: "text",
4261
- text: result.success ? result.result : `Error: ${result.error}`
4262
- }
4263
- ],
4264
- isError: !result.success
4265
- };
4266
- });
4267
- // ============================================================================
4268
- // iOS UI Interaction Tools (require an iOS UI driver)
4269
- // Default: AXe — brew install cameroncooke/axe/axe
4270
- // Alternative: IDB — brew install idb-companion (set IOS_DRIVER=idb)
4271
- // ============================================================================
4272
- // Tool: iOS button
4273
- server.registerTool("ios_button", {
4274
- description: "Press a hardware button on an iOS simulator." +
4275
- platformUniqueBanner("pressing iOS hardware buttons (HOME, LOCK, SIRI, APPLE_PAY)") +
4276
- " Requires an iOS UI driver: AXe (recommended: brew install cameroncooke/axe/axe) or IDB (brew install idb-companion)." +
4277
- "\nPURPOSE: Trigger iOS hardware buttons (HOME, LOCK, SIDE_BUTTON, SIRI, APPLE_PAY) that aren't reachable via on-screen tap." +
4278
- "\nWHEN TO USE: Send the app to background (HOME), lock the simulator (LOCK), or exercise Siri/Apple Pay flows." +
4279
- "\nSEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
4280
- inputSchema: {
4281
- button: z
4282
- .enum(IOS_BUTTON_TYPES)
4283
- .describe("Hardware button to press: HOME, LOCK, SIDE_BUTTON, SIRI, or APPLE_PAY"),
4284
- duration: z.coerce.number().optional().describe("Optional button press duration in seconds"),
4285
- udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
4286
- }
4287
- }, async ({ button, duration, udid }) => {
4288
- const result = await iosButton(button, { duration, udid });
4289
- return {
4290
- content: [
4291
- {
4292
- type: "text",
4293
- text: result.success ? result.result : `Error: ${result.error}`
4294
- }
4295
- ],
4296
- isError: !result.success
4297
- };
4298
- });
4299
- // Tool: Clear focused text input
4300
- registerToolWithTelemetry("clear_focused_input", {
4301
- description: "Clear the contents of the currently focused TextInput, updating React state correctly so controlled components (Formik, react-hook-form, useState) stay consistent." +
4302
- "\nPURPOSE: Reset whatever TextInput has focus to empty, with the React state owner notified via onChangeText. Use BEFORE typing a replacement value into a pre-filled field." +
4303
- "\nWHEN TO USE: After tap(testID=...) focuses an input that already has text. Pair with ios_input_text/android_input_text (or use their replace:true flag for one-shot)." +
4304
- "\nLIMITATIONS: Requires Bridgeless/Fabric (RN new architecture). Returns 'no focused TextInput' if nothing is focused — does not silently no-op." +
4305
- "\nSEE ALSO: dismiss_keyboard, ios_input_text({replace:true}), android_input_text({replace:true}). call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
4306
- inputSchema: {
4307
- device: z
4308
- .string()
4309
- .optional()
4310
- .describe("Optional device name (substring match). Uses default device if not specified.")
4311
- }
4312
- }, async ({ device }) => {
4313
- const result = await clearFocusedInput(device);
4314
- return {
4315
- content: [
4316
- {
4317
- type: "text",
4318
- text: result.success ? `Cleared focused input (via ${result.via}).` : `Error: ${result.error}`
4319
- }
4320
- ],
4321
- isError: !result.success
4322
- };
4323
- });
4324
- // Tool: Dismiss keyboard
4325
- registerToolWithTelemetry("dismiss_keyboard", {
4326
- description: "Blur the currently focused TextInput, dismissing the on-screen keyboard." +
4327
- "\nPURPOSE: Close the keyboard when it's blocking content beneath the input, or move focus off an input before a tap that would otherwise be intercepted." +
4328
- "\nWHEN TO USE: After typing into a field and before tapping a button that is hidden by the keyboard. Or to verify a 'tap outside dismisses' UX is wired up." +
4329
- "\nLIMITATIONS: Requires Bridgeless/Fabric (RN new architecture). Returns 'no focused TextInput' if nothing is focused.",
4330
- inputSchema: {
4331
- device: z
4332
- .string()
4333
- .optional()
4334
- .describe("Optional device name (substring match). Uses default device if not specified.")
4335
- }
4336
- }, async ({ device }) => {
4337
- const result = await dismissKeyboard(device);
4338
- return {
4339
- content: [
4340
- {
4341
- type: "text",
4342
- text: result.success
4343
- ? `Dismissed keyboard (nativeTag ${result.nativeTag}).`
4344
- : `Error: ${result.error}`
4345
- }
4346
- ],
4347
- isError: !result.success
4348
- };
4349
- });
4350
- // Tool: iOS input text
4351
- registerToolWithTelemetry("ios_input_text", {
4352
- description: "Type text on an iOS simulator." +
4353
- platformFallbackBanner("`tap(text=...)` — it auto-focuses TextInput via the fiber tree") +
4354
- " The text is typed into whichever field currently has focus (tap an input first). Mirrors `android_input_text` so cross-platform agents can use `<platform>_input_text` without branching on the iOS driver shell-out." +
4355
- "\nPURPOSE: Send keystrokes to the focused field on an iOS simulator via the active UI driver (AXe — preferred — or IDB)." +
4356
- "\nWHEN TO USE: Only after an input is already focused, or when `tap(testID=...)` on the input didn't take focus for some reason. Use the testID-first flow whenever possible — it's faster and survives UI repositioning." +
4357
- "\nREPLACE MODE: pass replace:true to clear the focused field first (via React onChangeText so controlled state stays consistent), then type the new value. Use for pre-filled fields where appending would corrupt the value." +
4358
- "\nLIMITATIONS: AXe types via the US-keyboard HID — non-ASCII characters (Cyrillic, CJK, Arabic) may not transmit correctly. If the active driver is AXe and the text contains non-ASCII chars, prefer pasting via the simulator pasteboard or setting IOS_DRIVER=idb." +
4359
- "\nSEE ALSO: call get_usage_guide(topic=\"interact\") for the full UI-interaction playbook.",
4360
- inputSchema: {
4361
- text: z.string().describe("Text to type into the currently focused field."),
4362
- replace: z
4363
- .boolean()
4364
- .optional()
4365
- .describe("If true, clear the focused TextInput via React onChangeText before typing. Use to set a pre-filled field to an exact value without concatenation. Requires Bridgeless/Fabric."),
4366
- device: z
4367
- .string()
4368
- .optional()
4369
- .describe("Optional RN device name (substring match) — needed by replace:true when multiple RN apps are connected, to disambiguate which device's focused input to clear. Single-device sessions can omit."),
4370
- udid: z.string().optional().describe("Optional simulator UDID (from list_ios_simulators). Uses booted simulator if not specified.")
4371
- }
4372
- }, async ({ text, replace, device, udid }) => {
4373
- const result = await inputTextWithReplace(text, replace === true, (t) => iosInputText(t, udid), () => clearFocusedInput(device));
4374
- return {
4375
- content: [
4376
- {
4377
- type: "text",
4378
- text: result.success ? result.result : `Error: ${result.error}`
4379
- }
4380
- ],
4381
- isError: !result.success
4382
- };
4383
- });
4384
- // Tool: Activate Pro license
4385
- registerToolWithTelemetry("activate_license", getActivateLicenseConfig(), handleActivateLicense);
4386
- // Tool: Delete account
4387
- registerToolWithTelemetry("delete_account", getDeleteAccountConfig(), handleDeleteAccount);
4388
- // Dev-only tool: reset local telemetry data
4389
- if (isDevMode()) {
4390
- registerToolWithTelemetry("reset_telemetry", {
4391
- description: "Clear local telemetry data file (/tmp/rn-devtools-telemetry.jsonl). Only available in development mode.",
4392
- inputSchema: {},
4393
- }, async () => {
4394
- if (existsSync(TELEMETRY_JSONL_PATH)) {
4395
- unlinkSync(TELEMETRY_JSONL_PATH);
4396
- return {
4397
- content: [{ type: "text", text: "Local telemetry data cleared." }],
4398
- };
4399
- }
4400
- return {
4401
- content: [{ type: "text", text: "No local telemetry data file found." }],
4402
- };
4403
- });
4404
- }
4405
- // Main function
38
+ installToolRegistryInterceptor(server);
39
+ registerAccountTools(server);
40
+ registerMetaTools(server, {
41
+ devMode: isDevMode(),
42
+ httpMode: process.argv.includes("--http"),
43
+ });
44
+ registerReduxTools(server);
45
+ registerExecutionTools(server);
46
+ registerLogTools(server);
47
+ registerNetworkTools(server);
48
+ registerBundleTools(server);
49
+ registerDeviceTools(server);
50
+ registerConnectionTools(server);
51
+ registerScreenshotTools(server);
52
+ registerInteractionTools(server);
53
+ registerComponentTools(server);
4406
54
  async function main() {
4407
- // Initialize telemetry (checks opt-out env var, loads/creates installation ID)
4408
- // License validation is lazy — runs on first tool use via ensureLicense()
4409
55
  initTelemetry();
4410
56
  identifyIfDevMode(getInstallationId());
4411
- // --- Eager usage check (pre-loads usage cache for tool-level gate) ---
57
+ // Pre-load usage cache so the tool-level gate has fresh data on first tool call.
4412
58
  const { ensureLicense } = await import("./core/license.js");
4413
59
  await ensureLicense();
4414
60
  const useHttp = process.argv.includes("--http");
4415
61
  const httpPort = parseInt(process.env.MCP_HTTP_PORT || "8600", 10);
4416
62
  if (useHttp) {
4417
- // Register dev meta-tool — proxies calls to any tool using the latest handlers
4418
- server.registerTool("dev", {
4419
- description: 'Development meta-tool for hot-reload testing. Use action="list" for a compact tool listing (name + first description line); pass filter to narrow by substring or verbose=true for full descriptions. ' +
4420
- 'Use action="call" with tool and args to invoke any tool using the latest code after hot-reload. ' +
4421
- "This tool always reflects the latest server code without needing a session restart.",
4422
- inputSchema: {
4423
- action: z.enum(["list", "call"]).describe('"list" to see all tools, "call" to invoke a tool'),
4424
- tool: z.string().optional().describe("Tool name to call (required when action is call)"),
4425
- args: z.record(z.any()).optional().describe("Arguments to pass to the tool (optional, default {})"),
4426
- filter: z.string().optional().describe("list only: case-insensitive substring filter on tool name"),
4427
- verbose: z.boolean().optional().describe("list only: include full multi-line descriptions (default: first line only)"),
4428
- },
4429
- }, async ({ action, tool, args, filter, verbose }) => {
4430
- if (action === "list") {
4431
- const needle = filter?.toLowerCase();
4432
- const tools = Array.from(toolRegistry.entries())
4433
- .filter(([name]) => !needle || name.toLowerCase().includes(needle))
4434
- .map(([name, { config }]) => {
4435
- const full = config.description || "";
4436
- return {
4437
- name,
4438
- description: verbose ? full : full.split("\n")[0],
4439
- };
4440
- });
4441
- return {
4442
- content: [{ type: "text", text: JSON.stringify(tools, null, 2) }],
4443
- };
4444
- }
4445
- if (action === "call") {
4446
- if (!tool) {
4447
- return {
4448
- content: [{ type: "text", text: 'Error: "tool" parameter is required when action is "call"' }],
4449
- isError: true,
4450
- };
4451
- }
4452
- const entry = toolRegistry.get(tool);
4453
- if (!entry) {
4454
- return {
4455
- content: [{ type: "text", text: `Error: Tool "${tool}" not found. Use action="list" to see available tools.` }],
4456
- isError: true,
4457
- };
4458
- }
4459
- return await entry.handler(args || {});
4460
- }
4461
- return {
4462
- content: [{ type: "text", text: 'Error: action must be "list" or "call"' }],
4463
- isError: true,
4464
- };
4465
- });
4466
63
  // HTTP transport mode — stateless for dev hot-reload
4467
64
  // Stateless = no session IDs, so server restarts don't break Claude Code's connection
4468
65
  const transport = new StreamableHTTPServerTransport({