react-native-ai-devtools 1.1.4

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 (147) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +1250 -0
  3. package/build/__tests__/helpers/fake-cdp-server.d.ts +56 -0
  4. package/build/__tests__/helpers/fake-cdp-server.d.ts.map +1 -0
  5. package/build/__tests__/helpers/fake-cdp-server.js +108 -0
  6. package/build/__tests__/helpers/fake-cdp-server.js.map +1 -0
  7. package/build/__tests__/integration/connection-health.test.d.ts +2 -0
  8. package/build/__tests__/integration/connection-health.test.d.ts.map +1 -0
  9. package/build/__tests__/integration/connection-health.test.js +151 -0
  10. package/build/__tests__/integration/connection-health.test.js.map +1 -0
  11. package/build/__tests__/integration/execute-in-app.test.d.ts +2 -0
  12. package/build/__tests__/integration/execute-in-app.test.d.ts.map +1 -0
  13. package/build/__tests__/integration/execute-in-app.test.js +115 -0
  14. package/build/__tests__/integration/execute-in-app.test.js.map +1 -0
  15. package/build/__tests__/integration/tools.test.d.ts +2 -0
  16. package/build/__tests__/integration/tools.test.d.ts.map +1 -0
  17. package/build/__tests__/integration/tools.test.js +228 -0
  18. package/build/__tests__/integration/tools.test.js.map +1 -0
  19. package/build/__tests__/setup.d.ts +2 -0
  20. package/build/__tests__/setup.d.ts.map +1 -0
  21. package/build/__tests__/setup.js +11 -0
  22. package/build/__tests__/setup.js.map +1 -0
  23. package/build/__tests__/unit/bundle.test.d.ts +2 -0
  24. package/build/__tests__/unit/bundle.test.d.ts.map +1 -0
  25. package/build/__tests__/unit/bundle.test.js +53 -0
  26. package/build/__tests__/unit/bundle.test.js.map +1 -0
  27. package/build/__tests__/unit/connection-health.test.d.ts +2 -0
  28. package/build/__tests__/unit/connection-health.test.d.ts.map +1 -0
  29. package/build/__tests__/unit/connection-health.test.js +28 -0
  30. package/build/__tests__/unit/connection-health.test.js.map +1 -0
  31. package/build/__tests__/unit/executor.test.d.ts +2 -0
  32. package/build/__tests__/unit/executor.test.d.ts.map +1 -0
  33. package/build/__tests__/unit/executor.test.js +79 -0
  34. package/build/__tests__/unit/executor.test.js.map +1 -0
  35. package/build/__tests__/unit/logs.test.d.ts +2 -0
  36. package/build/__tests__/unit/logs.test.d.ts.map +1 -0
  37. package/build/__tests__/unit/logs.test.js +81 -0
  38. package/build/__tests__/unit/logs.test.js.map +1 -0
  39. package/build/__tests__/unit/metro.test.d.ts +2 -0
  40. package/build/__tests__/unit/metro.test.d.ts.map +1 -0
  41. package/build/__tests__/unit/metro.test.js +61 -0
  42. package/build/__tests__/unit/metro.test.js.map +1 -0
  43. package/build/__tests__/unit/network.test.d.ts +2 -0
  44. package/build/__tests__/unit/network.test.d.ts.map +1 -0
  45. package/build/__tests__/unit/network.test.js +102 -0
  46. package/build/__tests__/unit/network.test.js.map +1 -0
  47. package/build/__tests__/unit/tap.test.d.ts +2 -0
  48. package/build/__tests__/unit/tap.test.d.ts.map +1 -0
  49. package/build/__tests__/unit/tap.test.js +157 -0
  50. package/build/__tests__/unit/tap.test.js.map +1 -0
  51. package/build/core/android.d.ts +265 -0
  52. package/build/core/android.d.ts.map +1 -0
  53. package/build/core/android.js +1413 -0
  54. package/build/core/android.js.map +1 -0
  55. package/build/core/bundle.d.ts +49 -0
  56. package/build/core/bundle.d.ts.map +1 -0
  57. package/build/core/bundle.js +368 -0
  58. package/build/core/bundle.js.map +1 -0
  59. package/build/core/connection.d.ts +43 -0
  60. package/build/core/connection.d.ts.map +1 -0
  61. package/build/core/connection.js +963 -0
  62. package/build/core/connection.js.map +1 -0
  63. package/build/core/connectionState.d.ts +108 -0
  64. package/build/core/connectionState.d.ts.map +1 -0
  65. package/build/core/connectionState.js +284 -0
  66. package/build/core/connectionState.js.map +1 -0
  67. package/build/core/errorScreenParser.d.ts +30 -0
  68. package/build/core/errorScreenParser.d.ts.map +1 -0
  69. package/build/core/errorScreenParser.js +198 -0
  70. package/build/core/errorScreenParser.js.map +1 -0
  71. package/build/core/executor.d.ts +113 -0
  72. package/build/core/executor.d.ts.map +1 -0
  73. package/build/core/executor.js +1877 -0
  74. package/build/core/executor.js.map +1 -0
  75. package/build/core/format.d.ts +8 -0
  76. package/build/core/format.d.ts.map +1 -0
  77. package/build/core/format.js +34 -0
  78. package/build/core/format.js.map +1 -0
  79. package/build/core/guides.d.ts +14 -0
  80. package/build/core/guides.d.ts.map +1 -0
  81. package/build/core/guides.js +261 -0
  82. package/build/core/guides.js.map +1 -0
  83. package/build/core/httpServer.d.ts +14 -0
  84. package/build/core/httpServer.d.ts.map +1 -0
  85. package/build/core/httpServer.js +2459 -0
  86. package/build/core/httpServer.js.map +1 -0
  87. package/build/core/httpServerProcess.d.ts +25 -0
  88. package/build/core/httpServerProcess.d.ts.map +1 -0
  89. package/build/core/httpServerProcess.js +153 -0
  90. package/build/core/httpServerProcess.js.map +1 -0
  91. package/build/core/index.d.ts +25 -0
  92. package/build/core/index.d.ts.map +1 -0
  93. package/build/core/index.js +53 -0
  94. package/build/core/index.js.map +1 -0
  95. package/build/core/ios.d.ts +214 -0
  96. package/build/core/ios.d.ts.map +1 -0
  97. package/build/core/ios.js +1232 -0
  98. package/build/core/ios.js.map +1 -0
  99. package/build/core/logs.d.ts +43 -0
  100. package/build/core/logs.d.ts.map +1 -0
  101. package/build/core/logs.js +144 -0
  102. package/build/core/logs.js.map +1 -0
  103. package/build/core/metro.d.ts +23 -0
  104. package/build/core/metro.d.ts.map +1 -0
  105. package/build/core/metro.js +96 -0
  106. package/build/core/metro.js.map +1 -0
  107. package/build/core/network.d.ts +43 -0
  108. package/build/core/network.d.ts.map +1 -0
  109. package/build/core/network.js +217 -0
  110. package/build/core/network.js.map +1 -0
  111. package/build/core/networkInterceptor.d.ts +3 -0
  112. package/build/core/networkInterceptor.d.ts.map +1 -0
  113. package/build/core/networkInterceptor.js +203 -0
  114. package/build/core/networkInterceptor.js.map +1 -0
  115. package/build/core/ocr.d.ts +69 -0
  116. package/build/core/ocr.d.ts.map +1 -0
  117. package/build/core/ocr.js +212 -0
  118. package/build/core/ocr.js.map +1 -0
  119. package/build/core/state.d.ts +17 -0
  120. package/build/core/state.d.ts.map +1 -0
  121. package/build/core/state.js +50 -0
  122. package/build/core/state.js.map +1 -0
  123. package/build/core/tap.d.ts +91 -0
  124. package/build/core/tap.d.ts.map +1 -0
  125. package/build/core/tap.js +542 -0
  126. package/build/core/tap.js.map +1 -0
  127. package/build/core/telemetry.d.ts +4 -0
  128. package/build/core/telemetry.d.ts.map +1 -0
  129. package/build/core/telemetry.js +289 -0
  130. package/build/core/telemetry.js.map +1 -0
  131. package/build/core/types.d.ts +134 -0
  132. package/build/core/types.d.ts.map +1 -0
  133. package/build/core/types.js +2 -0
  134. package/build/core/types.js.map +1 -0
  135. package/build/httpServerStandalone.d.ts +7 -0
  136. package/build/httpServerStandalone.d.ts.map +1 -0
  137. package/build/httpServerStandalone.js +31 -0
  138. package/build/httpServerStandalone.js.map +1 -0
  139. package/build/index.d.ts +3 -0
  140. package/build/index.d.ts.map +1 -0
  141. package/build/index.js +3012 -0
  142. package/build/index.js.map +1 -0
  143. package/build/pro/tap.d.ts +91 -0
  144. package/build/pro/tap.d.ts.map +1 -0
  145. package/build/pro/tap.js +542 -0
  146. package/build/pro/tap.js.map +1 -0
  147. package/package.json +63 -0
package/build/index.js ADDED
@@ -0,0 +1,3012 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { createServer as createHttpServer } from "node:http";
6
+ import { z } from "zod";
7
+ import { getGuideOverview, getGuideByTopic, getAvailableTopics } from "./core/guides.js";
8
+ import { tap } from "./pro/tap.js";
9
+ import { logBuffer, networkBuffer, bundleErrorBuffer, connectedApps, getActiveSimulatorUdid, scanMetroPorts, fetchDevices, selectMainDevice, connectToDevice, getConnectedApps, executeInApp, listDebugGlobals, inspectGlobal, reloadApp,
10
+ // React Component Inspection
11
+ getComponentTree, getScreenLayout, inspectComponent, findComponents, inspectAtPoint, toggleElementInspector, isInspectorActive, getInspectorSelection, getFirstConnectedApp, getLogs, searchLogs, getLogSummary, getNetworkRequests, searchNetworkRequests, getNetworkStats, formatRequestDetails,
12
+ // Connection state
13
+ getAllConnectionStates, getAllConnectionMetadata, getRecentGaps, formatDuration, cancelAllReconnectionTimers, clearAllConnectionState, suppressReconnection, clearReconnectionSuppression,
14
+ // Context health tracking
15
+ getContextHealth,
16
+ // Connection resilience
17
+ ensureConnection, checkAndEnsureConnection, getPassiveConnectionStatus,
18
+ // Bundle (Metro build errors)
19
+ connectMetroBuildEvents, disconnectMetroBuildEvents, getBundleErrors, getBundleStatusWithErrors, checkMetroState,
20
+ // Error screen parsing (OCR fallback)
21
+ parseErrorScreenText, formatParsedError,
22
+ // OCR
23
+ recognizeText, inferIOSDevicePixelRatio,
24
+ // Android
25
+ listAndroidDevices, androidScreenshot, androidInstallApp, androidLaunchApp, androidListPackages,
26
+ // Android UI Input (Phase 2)
27
+ ANDROID_KEY_EVENTS, androidTap, androidLongPress, androidSwipe, androidInputText, androidKeyEvent, androidGetScreenSize, androidGetDensity, androidGetStatusBarHeight,
28
+ // Android Accessibility (UI Hierarchy)
29
+ androidDescribeAll, androidDescribePoint,
30
+ // Android Element Finding (no screenshots)
31
+ androidFindElement, androidWaitForElement,
32
+ // iOS
33
+ listIOSSimulators, iosScreenshot, iosInstallApp, iosLaunchApp, iosOpenUrl, iosTerminateApp, iosBootSimulator,
34
+ // iOS IDB-based UI tools
35
+ iosTap, iosSwipe, iosInputText, iosButton, iosKeyEvent, iosKeySequence, iosDescribeAll, iosDescribePoint, IOS_BUTTON_TYPES,
36
+ // iOS Element Finding (no screenshots)
37
+ iosFindElement, iosWaitForElement,
38
+ // Debug HTTP Server
39
+ startDebugHttpServer, getDebugServerPort,
40
+ // Telemetry
41
+ initTelemetry, trackToolInvocation, getTargetPlatform,
42
+ // Format utilities (TONL)
43
+ formatLogsAsTonl, formatNetworkAsTonl } from "./core/index.js";
44
+ // Create MCP server
45
+ const server = new McpServer({
46
+ name: "react-native-ai-debugger",
47
+ version: "1.0.0"
48
+ }, {
49
+ instructions: "React Native debugging MCP server. Call get_usage_guide to learn recommended workflows for all tools. Quick start: scan_metro → get_logs / search_logs (console debugging) → ios_screenshot → tap(text=\"Submit\") or tap(x, y) (interact with UI)."
50
+ });
51
+ // ============================================================================
52
+ // Telemetry Wrapper
53
+ // ============================================================================
54
+ /**
55
+ * Parse JPEG dimensions from a raw buffer by scanning for the SOF marker.
56
+ * Only needs the first ~2KB of the image to find dimensions.
57
+ */
58
+ function getJpegDimensions(buffer) {
59
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8)
60
+ return null;
61
+ let offset = 2;
62
+ while (offset < buffer.length - 9) {
63
+ if (buffer[offset] !== 0xff)
64
+ return null;
65
+ const marker = buffer[offset + 1];
66
+ // SOF markers: C0-CF except C4 (DHT) and CC (DAC)
67
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xcc) {
68
+ const height = buffer.readUInt16BE(offset + 5);
69
+ const width = buffer.readUInt16BE(offset + 7);
70
+ return { width, height };
71
+ }
72
+ // Skip segment (read its length)
73
+ const segLength = buffer.readUInt16BE(offset + 2);
74
+ offset += 2 + segLength;
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * Estimate how many tokens an image will consume in Claude's vision encoder.
80
+ * Per Anthropic docs, Claude auto-resizes images to fit within:
81
+ * 1) 1568px on any side, AND
82
+ * 2) ~1.15 megapixels total (whichever is hit first)
83
+ * Then tokens ≈ (width * height) / 750 (capped at ~1,600 per image).
84
+ * We only decode the first ~2KB of the base64 string to read JPEG dimensions.
85
+ */
86
+ function estimateImageTokens(base64Data) {
87
+ try {
88
+ // Decode only the first ~2KB (2732 base64 chars ≈ 2048 bytes) to find JPEG header
89
+ const headerBase64 = base64Data.slice(0, 2732);
90
+ const buffer = Buffer.from(headerBase64, "base64");
91
+ const dims = getJpegDimensions(buffer);
92
+ if (!dims)
93
+ return Math.ceil(base64Data.length / 4); // fallback for non-JPEG
94
+ let { width, height } = dims;
95
+ // Step 1: Claude resizes to fit within 1568px on any side
96
+ const MAX_CLAUDE_DIM = 1568;
97
+ if (width > MAX_CLAUDE_DIM || height > MAX_CLAUDE_DIM) {
98
+ const scale = MAX_CLAUDE_DIM / Math.max(width, height);
99
+ width = Math.round(width * scale);
100
+ height = Math.round(height * scale);
101
+ }
102
+ // Step 2: Claude further resizes to fit within ~1.15 megapixels
103
+ const MAX_PIXELS = 1_150_000;
104
+ if (width * height > MAX_PIXELS) {
105
+ const scale = Math.sqrt(MAX_PIXELS / (width * height));
106
+ width = Math.round(width * scale);
107
+ height = Math.round(height * scale);
108
+ }
109
+ return Math.ceil((width * height) / 750);
110
+ }
111
+ catch {
112
+ return Math.ceil(base64Data.length / 4); // fallback
113
+ }
114
+ }
115
+ // Registry for dev meta-tool — stores handlers and configs for dynamic dispatch
116
+ /* eslint-disable @typescript-eslint/no-explicit-any */
117
+ const toolRegistry = new Map();
118
+ function registerToolWithTelemetry(toolName, config, handler) {
119
+ toolRegistry.set(toolName, { config, handler });
120
+ server.registerTool(toolName, config, async (args) => {
121
+ const startTime = Date.now();
122
+ let success = true;
123
+ let errorMessage;
124
+ let errorContext;
125
+ let inputTokens;
126
+ let outputTokens;
127
+ try {
128
+ inputTokens = Math.ceil(JSON.stringify(args).length / 4);
129
+ }
130
+ catch {
131
+ /* circular refs — leave undefined */
132
+ }
133
+ try {
134
+ const result = await handler(args);
135
+ // Check if result indicates an error
136
+ if (result?.isError) {
137
+ success = false;
138
+ errorMessage = result.content?.[0]?.text || "Unknown error";
139
+ // Extract error context if provided (e.g., the expression that caused a syntax error)
140
+ errorContext = result._errorContext;
141
+ }
142
+ if (Array.isArray(result?.content)) {
143
+ let totalTokens = 0;
144
+ for (const item of result.content) {
145
+ if (item.type === "text" && typeof item.text === "string") {
146
+ totalTokens += Math.ceil(item.text.length / 4);
147
+ }
148
+ else if (item.type === "image" && typeof item.data === "string") {
149
+ totalTokens += estimateImageTokens(item.data);
150
+ }
151
+ }
152
+ if (totalTokens > 0)
153
+ outputTokens = totalTokens;
154
+ }
155
+ return result;
156
+ }
157
+ catch (error) {
158
+ success = false;
159
+ errorMessage = error instanceof Error ? error.message : String(error);
160
+ throw error;
161
+ }
162
+ finally {
163
+ const duration = Date.now() - startTime;
164
+ trackToolInvocation(toolName, success, duration, errorMessage, errorContext, inputTokens, outputTokens, getTargetPlatform());
165
+ }
166
+ });
167
+ }
168
+ /* eslint-enable @typescript-eslint/no-explicit-any */
169
+ // Tool: Usage guide for agents
170
+ registerToolWithTelemetry("get_usage_guide", {
171
+ 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.",
172
+ inputSchema: {
173
+ topic: z
174
+ .string()
175
+ .optional()
176
+ .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.")
177
+ }
178
+ }, async ({ topic }) => {
179
+ if (!topic) {
180
+ return {
181
+ content: [{ type: "text", text: getGuideOverview() }]
182
+ };
183
+ }
184
+ const guide = getGuideByTopic(topic);
185
+ if (!guide) {
186
+ const available = getAvailableTopics().join(", ");
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: `Unknown topic: "${topic}". Available topics: ${available}`
192
+ }
193
+ ],
194
+ isError: true
195
+ };
196
+ }
197
+ return {
198
+ content: [{ type: "text", text: guide }]
199
+ };
200
+ });
201
+ // Tool: Scan for Metro servers
202
+ registerToolWithTelemetry("scan_metro", {
203
+ 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.",
204
+ inputSchema: {
205
+ startPort: z.coerce.number().optional().default(8081).describe("Start port for scanning (default: 8081)"),
206
+ endPort: z.coerce.number().optional().default(19002).describe("End port for scanning (default: 19002)")
207
+ }
208
+ }, async ({ startPort, endPort }) => {
209
+ // Clear reconnection suppression (in case user previously called disconnect_metro)
210
+ clearReconnectionSuppression();
211
+ const openPorts = await scanMetroPorts(startPort, endPort);
212
+ if (openPorts.length === 0) {
213
+ return {
214
+ content: [
215
+ {
216
+ type: "text",
217
+ text: "No Metro servers found. Make sure Metro bundler is running (npm start or expo start)."
218
+ }
219
+ ]
220
+ };
221
+ }
222
+ // Fetch devices from each port and connect
223
+ const results = [];
224
+ for (const port of openPorts) {
225
+ const devices = await fetchDevices(port);
226
+ if (devices.length === 0) {
227
+ results.push(`Port ${port}: No devices found`);
228
+ continue;
229
+ }
230
+ results.push(`Port ${port}: Found ${devices.length} device(s)`);
231
+ const mainDevice = selectMainDevice(devices);
232
+ if (mainDevice) {
233
+ try {
234
+ const connectionResult = await connectToDevice(mainDevice, port);
235
+ results.push(` - ${connectionResult}`);
236
+ // Also connect to Metro build events for this port
237
+ try {
238
+ await connectMetroBuildEvents(port);
239
+ results.push(` - Connected to Metro build events`);
240
+ }
241
+ catch {
242
+ // Build events connection is optional, don't fail the scan
243
+ }
244
+ }
245
+ catch (error) {
246
+ results.push(` - Failed: ${error}`);
247
+ }
248
+ }
249
+ }
250
+ return {
251
+ content: [
252
+ {
253
+ type: "text",
254
+ text: `Metro scan results:\n${results.join("\n")}`
255
+ }
256
+ ]
257
+ };
258
+ });
259
+ // Tool: Get connected apps
260
+ registerToolWithTelemetry("get_apps", {
261
+ description: "List currently connected React Native apps and their connection status. If no apps are connected, run scan_metro first to establish a connection.",
262
+ inputSchema: {}
263
+ }, async () => {
264
+ const connections = getConnectedApps();
265
+ if (connections.length === 0) {
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: 'No apps connected. Run "scan_metro" first to discover and connect to running apps.'
271
+ }
272
+ ]
273
+ };
274
+ }
275
+ const status = connections.map(({ app, isConnected }) => {
276
+ const state = isConnected ? "Connected" : "Disconnected";
277
+ return `${app.deviceInfo.title} (${app.deviceInfo.deviceName}): ${state}`;
278
+ });
279
+ // Include active iOS simulator info if available
280
+ const activeSimulatorUdid = getActiveSimulatorUdid();
281
+ const simulatorInfo = activeSimulatorUdid
282
+ ? `\nActive iOS Simulator (auto-scoped): ${activeSimulatorUdid}`
283
+ : "\nNo iOS simulator linked (iOS tools will use first booted simulator)";
284
+ return {
285
+ content: [
286
+ {
287
+ type: "text",
288
+ text: `Connected apps:\n${status.join("\n")}${simulatorInfo}\n\nTotal logs in buffer: ${logBuffer.size}`
289
+ }
290
+ ]
291
+ };
292
+ });
293
+ // Tool: Get connection status (detailed health and gap tracking)
294
+ registerToolWithTelemetry("get_connection_status", {
295
+ description: "Get detailed connection health status including uptime, recent disconnects/reconnects, and connection gaps that may indicate missing data.",
296
+ inputSchema: {}
297
+ }, async () => {
298
+ const connections = getConnectedApps();
299
+ const states = getAllConnectionStates();
300
+ const metadata = getAllConnectionMetadata();
301
+ const lines = [];
302
+ lines.push("=== Connection Status ===\n");
303
+ if (connections.length === 0 && states.size === 0) {
304
+ lines.push("No connections established. Run scan_metro to connect.");
305
+ return {
306
+ content: [{ type: "text", text: lines.join("\n") }]
307
+ };
308
+ }
309
+ // Show active connections
310
+ for (const { key, app, isConnected } of connections) {
311
+ const state = states.get(key);
312
+ const contextHealth = getContextHealth(key);
313
+ lines.push(`--- ${app.deviceInfo.title} (Port ${app.port}) ---`);
314
+ lines.push(` Status: ${isConnected ? "CONNECTED" : "DISCONNECTED"}`);
315
+ if (state) {
316
+ if (state.lastConnectedTime) {
317
+ const uptime = Date.now() - state.lastConnectedTime.getTime();
318
+ lines.push(` Connected since: ${state.lastConnectedTime.toLocaleTimeString()}`);
319
+ lines.push(` Uptime: ${formatDuration(uptime)}`);
320
+ }
321
+ if (state.status === "reconnecting") {
322
+ lines.push(` Reconnecting: Attempt ${state.reconnectionAttempts}`);
323
+ }
324
+ // Show recent gaps (last 5 minutes)
325
+ if (state.connectionGaps.length > 0) {
326
+ const recentGaps = state.connectionGaps.filter((g) => Date.now() - g.disconnectedAt.getTime() < 300000);
327
+ if (recentGaps.length > 0) {
328
+ lines.push(` Recent gaps: ${recentGaps.length}`);
329
+ for (const gap of recentGaps.slice(-3)) {
330
+ const duration = gap.durationMs ? formatDuration(gap.durationMs) : "ongoing";
331
+ lines.push(` - ${gap.disconnectedAt.toLocaleTimeString()} (${duration}): ${gap.reason}`);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ // Show context health
337
+ if (contextHealth) {
338
+ lines.push(` Context Health:`);
339
+ lines.push(` Context ID: ${contextHealth.contextId ?? "unknown"}`);
340
+ lines.push(` Status: ${contextHealth.isStale ? "STALE" : "HEALTHY"}`);
341
+ if (contextHealth.lastHealthCheck) {
342
+ const healthResult = contextHealth.lastHealthCheckSuccess ? "PASS" : "FAIL";
343
+ lines.push(` Last Check: ${contextHealth.lastHealthCheck.toLocaleTimeString()} (${healthResult})`);
344
+ }
345
+ if (contextHealth.lastContextCreated) {
346
+ lines.push(` Context Created: ${contextHealth.lastContextCreated.toLocaleTimeString()}`);
347
+ }
348
+ if (contextHealth.lastContextDestroyed) {
349
+ lines.push(` Context Destroyed: ${contextHealth.lastContextDestroyed.toLocaleTimeString()}`);
350
+ }
351
+ }
352
+ lines.push("");
353
+ }
354
+ // Show disconnected/reconnecting states without active connections
355
+ for (const [key, state] of states.entries()) {
356
+ if (!connections.find((c) => c.key === key)) {
357
+ const meta = metadata.get(key);
358
+ lines.push(`--- ${meta?.deviceInfo.title || key} (Disconnected) ---`);
359
+ lines.push(` Status: ${state.status.toUpperCase()}`);
360
+ if (state.lastDisconnectTime) {
361
+ lines.push(` Disconnected at: ${state.lastDisconnectTime.toLocaleTimeString()}`);
362
+ }
363
+ if (state.reconnectionAttempts > 0) {
364
+ lines.push(` Reconnection attempts: ${state.reconnectionAttempts}`);
365
+ }
366
+ lines.push("");
367
+ }
368
+ }
369
+ return {
370
+ content: [{ type: "text", text: lines.join("\n") }]
371
+ };
372
+ });
373
+ // Tool: Ensure connection health
374
+ registerToolWithTelemetry("ensure_connection", {
375
+ 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.",
376
+ inputSchema: {
377
+ port: z.coerce.number().optional().describe("Metro port (default: auto-detect)"),
378
+ healthCheck: z
379
+ .boolean()
380
+ .optional()
381
+ .default(true)
382
+ .describe("Run health check to verify page context is responsive (default: true)"),
383
+ forceRefresh: z
384
+ .boolean()
385
+ .optional()
386
+ .default(false)
387
+ .describe("Force close existing connection and reconnect (default: false)")
388
+ }
389
+ }, async ({ port, healthCheck, forceRefresh }) => {
390
+ const result = await ensureConnection({ port, healthCheck, forceRefresh });
391
+ if (!result.connected) {
392
+ return {
393
+ content: [
394
+ {
395
+ type: "text",
396
+ text: result.error || "Connection failed: Unknown error"
397
+ }
398
+ ],
399
+ isError: true
400
+ };
401
+ }
402
+ const lines = [];
403
+ lines.push("=== Connection Ensured ===\n");
404
+ if (result.connectionInfo) {
405
+ lines.push(`Device: ${result.connectionInfo.deviceTitle}`);
406
+ lines.push(`Port: ${result.connectionInfo.port}`);
407
+ lines.push(`Uptime: ${result.connectionInfo.uptime}`);
408
+ if (result.connectionInfo.contextId !== null) {
409
+ lines.push(`Context ID: ${result.connectionInfo.contextId}`);
410
+ }
411
+ }
412
+ lines.push("");
413
+ lines.push(`Reconnected: ${result.wasReconnected ? "Yes" : "No"}`);
414
+ lines.push(`Health Check: ${result.healthCheckPassed ? "PASSED" : "FAILED"}`);
415
+ if (!result.healthCheckPassed) {
416
+ lines.push("");
417
+ lines.push("Warning: Health check failed. The page context may be stale.");
418
+ lines.push("Consider using forceRefresh=true or reload_app to get a fresh context.");
419
+ }
420
+ return {
421
+ content: [{ type: "text", text: lines.join("\n") }]
422
+ };
423
+ });
424
+ // Tool: Get console logs
425
+ registerToolWithTelemetry("get_logs", {
426
+ 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.",
427
+ inputSchema: {
428
+ maxLogs: z.coerce
429
+ .number()
430
+ .optional()
431
+ .default(50)
432
+ .describe("Maximum number of logs to return (default: 50)"),
433
+ level: z
434
+ .enum(["all", "log", "warn", "error", "info", "debug"])
435
+ .optional()
436
+ .default("all")
437
+ .describe("Filter by log level (default: all)"),
438
+ startFromText: z.string().optional().describe("Start from the first log line containing this text"),
439
+ maxMessageLength: z.coerce
440
+ .number()
441
+ .optional()
442
+ .default(500)
443
+ .describe("Max characters per message (default: 500, set to 0 for unlimited). Tip: Use lower values for overview, higher when debugging specific data structures."),
444
+ verbose: z
445
+ .boolean()
446
+ .optional()
447
+ .default(false)
448
+ .describe("Disable all truncation and return full messages. Tip: Use with lower maxLogs (e.g., 10) to avoid token overload when inspecting large objects."),
449
+ format: z
450
+ .enum(["text", "tonl"])
451
+ .optional()
452
+ .default("tonl")
453
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format, ~30-50% smaller)"),
454
+ summary: z
455
+ .boolean()
456
+ .optional()
457
+ .default(false)
458
+ .describe("Return summary statistics instead of full logs (count by level + last 5 messages). Use for quick overview.")
459
+ }
460
+ }, async ({ maxLogs, level, startFromText, maxMessageLength, verbose, format, summary }) => {
461
+ // Return summary if requested
462
+ if (summary) {
463
+ const summaryText = getLogSummary(logBuffer, { lastN: 5, maxMessageLength: 100 });
464
+ let connectionWarning = "";
465
+ if (logBuffer.size === 0) {
466
+ const status = await checkAndEnsureConnection();
467
+ connectionWarning = status.message ? `\n\n${status.message}` : "";
468
+ }
469
+ return {
470
+ content: [
471
+ {
472
+ type: "text",
473
+ text: `Log Summary:\n\n${summaryText}${connectionWarning}`
474
+ }
475
+ ]
476
+ };
477
+ }
478
+ const { logs, count, formatted } = getLogs(logBuffer, {
479
+ maxLogs,
480
+ level,
481
+ startFromText,
482
+ maxMessageLength,
483
+ verbose
484
+ });
485
+ // Check connection health
486
+ let connectionWarning = "";
487
+ if (count === 0) {
488
+ const status = await checkAndEnsureConnection();
489
+ connectionWarning = status.message ? `\n\n${status.message}` : "";
490
+ }
491
+ else {
492
+ const passive = getPassiveConnectionStatus();
493
+ connectionWarning = !passive.connected
494
+ ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
495
+ : "";
496
+ }
497
+ // Check for recent connection gaps
498
+ const warningThresholdMs = 30000; // 30 seconds
499
+ const recentGaps = getRecentGaps(warningThresholdMs);
500
+ let gapWarning = "";
501
+ if (recentGaps.length > 0) {
502
+ const latestGap = recentGaps[recentGaps.length - 1];
503
+ const gapDuration = latestGap.durationMs || Date.now() - latestGap.disconnectedAt.getTime();
504
+ if (latestGap.reconnectedAt) {
505
+ const secAgo = Math.round((Date.now() - latestGap.reconnectedAt.getTime()) / 1000);
506
+ gapWarning = `\n\n[WARNING] Connection was restored ${secAgo}s ago. Some logs may have been missed during the ${formatDuration(gapDuration)} gap.`;
507
+ }
508
+ else {
509
+ gapWarning = `\n\n[WARNING] Connection is currently disconnected. Logs may be incomplete.`;
510
+ }
511
+ }
512
+ const startNote = startFromText ? ` (starting from "${startFromText}")` : "";
513
+ // Use TONL format if requested
514
+ if (format === "tonl") {
515
+ const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
516
+ return {
517
+ content: [
518
+ {
519
+ type: "text",
520
+ text: `React Native Console Logs (${count} entries)${startNote}:\n\n${tonlOutput}${gapWarning}${connectionWarning}`
521
+ }
522
+ ]
523
+ };
524
+ }
525
+ return {
526
+ content: [
527
+ {
528
+ type: "text",
529
+ text: `React Native Console Logs (${count} entries)${startNote}:\n\n${formatted}${gapWarning}${connectionWarning}`
530
+ }
531
+ ]
532
+ };
533
+ });
534
+ // Tool: Search logs
535
+ registerToolWithTelemetry("search_logs", {
536
+ description: "Search console logs for text (case-insensitive)",
537
+ inputSchema: {
538
+ text: z.string().describe("Text to search for in log messages"),
539
+ maxResults: z.coerce
540
+ .number()
541
+ .optional()
542
+ .default(50)
543
+ .describe("Maximum number of results to return (default: 50)"),
544
+ maxMessageLength: z.coerce
545
+ .number()
546
+ .optional()
547
+ .default(500)
548
+ .describe("Max characters per message (default: 500, set to 0 for unlimited)"),
549
+ verbose: z.boolean().optional().default(false).describe("Disable all truncation and return full messages"),
550
+ format: z
551
+ .enum(["text", "tonl"])
552
+ .optional()
553
+ .default("tonl")
554
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)")
555
+ }
556
+ }, async ({ text, maxResults, maxMessageLength, verbose, format }) => {
557
+ const { logs, count, formatted } = searchLogs(logBuffer, text, { maxResults, maxMessageLength, verbose });
558
+ // Check connection health
559
+ let connectionWarning = "";
560
+ if (count === 0) {
561
+ const status = await checkAndEnsureConnection();
562
+ connectionWarning = status.message ? `\n\n${status.message}` : "";
563
+ }
564
+ else {
565
+ const passive = getPassiveConnectionStatus();
566
+ connectionWarning = !passive.connected
567
+ ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
568
+ : "";
569
+ }
570
+ // Use TONL format if requested
571
+ if (format === "tonl") {
572
+ const tonlOutput = formatLogsAsTonl(logs, { maxMessageLength: verbose ? 0 : maxMessageLength });
573
+ return {
574
+ content: [
575
+ {
576
+ type: "text",
577
+ text: `Search results for "${text}" (${count} matches):\n\n${tonlOutput}${connectionWarning}`
578
+ }
579
+ ]
580
+ };
581
+ }
582
+ return {
583
+ content: [
584
+ {
585
+ type: "text",
586
+ text: `Search results for "${text}" (${count} matches):\n\n${formatted}${connectionWarning}`
587
+ }
588
+ ]
589
+ };
590
+ });
591
+ // Tool: Clear logs
592
+ registerToolWithTelemetry("clear_logs", {
593
+ description: "Clear the log buffer",
594
+ inputSchema: {}
595
+ }, async () => {
596
+ const count = logBuffer.clear();
597
+ return {
598
+ content: [
599
+ {
600
+ type: "text",
601
+ text: `Cleared ${count} log entries from buffer.`
602
+ }
603
+ ]
604
+ };
605
+ });
606
+ // Tool: Connect to specific Metro port
607
+ registerToolWithTelemetry("connect_metro", {
608
+ description: "Connect to a Metro server on a specific port. Use this when you know the exact port, otherwise use scan_metro which auto-detects. Establishes the WebSocket connection needed for debugging tools.",
609
+ inputSchema: {
610
+ port: z.coerce.number().default(8081).describe("Metro server port (default: 8081)")
611
+ }
612
+ }, async ({ port }) => {
613
+ try {
614
+ const devices = await fetchDevices(port);
615
+ if (devices.length === 0) {
616
+ return {
617
+ content: [
618
+ {
619
+ type: "text",
620
+ text: `No devices found on port ${port}. Make sure the app is running.`
621
+ }
622
+ ]
623
+ };
624
+ }
625
+ const results = [`Found ${devices.length} device(s) on port ${port}:`];
626
+ for (const device of devices) {
627
+ try {
628
+ const result = await connectToDevice(device, port);
629
+ results.push(` - ${result}`);
630
+ }
631
+ catch (error) {
632
+ results.push(` - ${device.title}: Failed - ${error}`);
633
+ }
634
+ }
635
+ // Also connect to Metro build events
636
+ try {
637
+ await connectMetroBuildEvents(port);
638
+ results.push(` - Connected to Metro build events`);
639
+ }
640
+ catch {
641
+ // Build events connection is optional
642
+ }
643
+ return {
644
+ content: [
645
+ {
646
+ type: "text",
647
+ text: results.join("\n")
648
+ }
649
+ ]
650
+ };
651
+ }
652
+ catch (error) {
653
+ return {
654
+ content: [
655
+ {
656
+ type: "text",
657
+ text: `Failed to connect: ${error}`
658
+ }
659
+ ]
660
+ };
661
+ }
662
+ });
663
+ // Tool: Disconnect from Metro
664
+ registerToolWithTelemetry("disconnect_metro", {
665
+ description: "Disconnect from all Metro servers and stop auto-reconnection. Use this when you want to switch to the built-in React Native debugger (which requires the CDP slot to be free). Log and network buffers are preserved. Reconnect later with scan_metro.",
666
+ inputSchema: {}
667
+ }, async () => {
668
+ const connections = getConnectedApps();
669
+ if (connections.length === 0) {
670
+ return {
671
+ content: [
672
+ {
673
+ type: "text",
674
+ text: "No active Metro connections to disconnect."
675
+ }
676
+ ]
677
+ };
678
+ }
679
+ const disconnected = [];
680
+ // Suppress reconnection BEFORE closing sockets
681
+ // (close handlers fire async and would re-schedule reconnection)
682
+ suppressReconnection();
683
+ cancelAllReconnectionTimers();
684
+ // Close all CDP WebSocket connections
685
+ for (const [key, app] of connectedApps.entries()) {
686
+ try {
687
+ app.ws.close();
688
+ }
689
+ catch {
690
+ // Ignore close errors
691
+ }
692
+ disconnected.push(`${app.deviceInfo.title} (port ${app.port})`);
693
+ connectedApps.delete(key);
694
+ }
695
+ // Disconnect Metro build events WebSocket
696
+ disconnectMetroBuildEvents();
697
+ // Clear connection state (but NOT log/network buffers)
698
+ clearAllConnectionState();
699
+ const lines = [
700
+ `Disconnected from ${disconnected.length} app(s):`,
701
+ ...disconnected.map((d) => ` - ${d}`),
702
+ "",
703
+ "Metro CDP connection is now free for the built-in React Native debugger.",
704
+ "Log and network buffers are preserved.",
705
+ 'Use "scan_metro" to reconnect when ready.'
706
+ ];
707
+ return {
708
+ content: [{ type: "text", text: lines.join("\n") }]
709
+ };
710
+ });
711
+ // Tool: Execute JavaScript in app
712
+ registerToolWithTelemetry("execute_in_app", {
713
+ 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" +
714
+ "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" +
715
+ "LIMITATIONS (Hermes engine):\n" +
716
+ "- NO require() or import — only pre-existing globals are available\n" +
717
+ "- NO async/await syntax — use simple expressions or promise chains (.then())\n" +
718
+ "- NO emoji or non-ASCII characters in string literals — causes parse errors\n" +
719
+ "- Keep expressions simple and synchronous when possible\n\n" +
720
+ "GOOD examples: `__DEV__`, `__APOLLO_CLIENT__.cache.extract()`, `__EXPO_ROUTER__.navigate('/settings')`\n" +
721
+ "BAD examples: `async () => { await fetch(...) }`, `require('react-native')`, `console.log('\\u{1F600}')`",
722
+ inputSchema: {
723
+ expression: z
724
+ .string()
725
+ .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."),
726
+ awaitPromise: z.coerce
727
+ .boolean()
728
+ .optional()
729
+ .default(true)
730
+ .describe("Whether to await promises (default: true)"),
731
+ maxResultLength: z.coerce
732
+ .number()
733
+ .optional()
734
+ .default(2000)
735
+ .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."),
736
+ verbose: z
737
+ .boolean()
738
+ .optional()
739
+ .default(false)
740
+ .describe("Disable result truncation. Tip: Be cautious - Redux stores or large state can return 10KB+.")
741
+ }
742
+ }, async ({ expression, awaitPromise, maxResultLength, verbose }) => {
743
+ const result = await executeInApp(expression, awaitPromise);
744
+ if (!result.success) {
745
+ let errorText = `Error: ${result.error}`;
746
+ // If the error is a ReferenceError (accessing a global that doesn't exist),
747
+ // guide the agent to expose the variable as a global first
748
+ if (result.error?.includes("ReferenceError")) {
749
+ errorText +=
750
+ "\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.";
751
+ }
752
+ return {
753
+ content: [
754
+ {
755
+ type: "text",
756
+ text: errorText
757
+ }
758
+ ],
759
+ isError: true,
760
+ // Include expression as context for telemetry (helps debug syntax errors)
761
+ _errorContext: expression
762
+ };
763
+ }
764
+ let resultText = result.result ?? "undefined";
765
+ // Apply truncation unless verbose or unlimited
766
+ if (!verbose && maxResultLength > 0 && resultText.length > maxResultLength) {
767
+ resultText =
768
+ resultText.slice(0, maxResultLength) + `... [truncated: ${result.result?.length ?? 0} chars total]`;
769
+ }
770
+ return {
771
+ content: [
772
+ {
773
+ type: "text",
774
+ text: resultText
775
+ }
776
+ ]
777
+ };
778
+ });
779
+ // Tool: List debug globals available in the app
780
+ registerToolWithTelemetry("list_debug_globals", {
781
+ description: "List globally available debugging objects in the connected React Native app (Apollo Client, Redux store, React DevTools, etc.). Use this to discover what state management and debugging tools are available.",
782
+ inputSchema: {}
783
+ }, async () => {
784
+ const result = await listDebugGlobals();
785
+ if (!result.success) {
786
+ return {
787
+ content: [
788
+ {
789
+ type: "text",
790
+ text: `Error: ${result.error}`
791
+ }
792
+ ],
793
+ isError: true
794
+ };
795
+ }
796
+ return {
797
+ content: [
798
+ {
799
+ type: "text",
800
+ text: `Available debug globals in the app:\n\n${result.result}`
801
+ }
802
+ ]
803
+ };
804
+ });
805
+ // Tool: Inspect a global object to see its properties and types
806
+ registerToolWithTelemetry("inspect_global", {
807
+ description: "Inspect a global object to see its properties, types, and whether they are callable functions. Use this BEFORE calling methods on unfamiliar objects to avoid errors.",
808
+ inputSchema: {
809
+ objectName: z
810
+ .string()
811
+ .describe("Name of the global object to inspect (e.g., '__EXPO_ROUTER__', '__APOLLO_CLIENT__')")
812
+ }
813
+ }, async ({ objectName }) => {
814
+ const result = await inspectGlobal(objectName);
815
+ if (!result.success) {
816
+ let errorText = `Error: ${result.error}`;
817
+ // If the error is a ReferenceError (accessing a global that doesn't exist),
818
+ // guide the agent to expose the variable as a global first
819
+ if (result.error?.includes("ReferenceError")) {
820
+ errorText += `\n\nNOTE: '${objectName}' is not exposed as a global variable. To inspect it, first assign it to a global in your app code (e.g., \`globalThis.${objectName} = ${objectName.replace(/^__/, "").replace(/__$/, "")};\`), then call inspect_global again. Use list_debug_globals to see what globals ARE currently available.`;
821
+ }
822
+ return {
823
+ content: [
824
+ {
825
+ type: "text",
826
+ text: errorText
827
+ }
828
+ ],
829
+ isError: true
830
+ };
831
+ }
832
+ return {
833
+ content: [
834
+ {
835
+ type: "text",
836
+ text: `Properties of ${objectName}:\n\n${result.result}`
837
+ }
838
+ ]
839
+ };
840
+ });
841
+ // ============================================================================
842
+ // React Component Inspection Tools
843
+ // ============================================================================
844
+ // Tool: Get the React component tree
845
+ registerToolWithTelemetry("get_component_tree", {
846
+ description: "Get the React component tree from the running app. **RECOMMENDED**: Use focusedOnly=true with structureOnly=true for a token-efficient overview of just the active screen (~1-2KB). This skips navigation wrappers and global overlays, showing only what's actually visible.",
847
+ inputSchema: {
848
+ focusedOnly: z
849
+ .boolean()
850
+ .optional()
851
+ .default(false)
852
+ .describe("Return only the focused/active screen subtree, skipping navigation wrappers and overlays. Dramatically reduces output size. (Recommended: true)"),
853
+ structureOnly: z
854
+ .boolean()
855
+ .optional()
856
+ .default(false)
857
+ .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."),
858
+ maxDepth: z
859
+ .number()
860
+ .optional()
861
+ .describe("Maximum tree depth (default: 25 for focusedOnly+structureOnly, 40 for structureOnly, 100 for full mode)"),
862
+ includeProps: z
863
+ .boolean()
864
+ .optional()
865
+ .default(false)
866
+ .describe("Include component props (excluding children and style). Ignored if structureOnly=true."),
867
+ includeStyles: z
868
+ .boolean()
869
+ .optional()
870
+ .default(false)
871
+ .describe("Include layout styles (padding, margin, flex, etc.). Ignored if structureOnly=true."),
872
+ hideInternals: z
873
+ .boolean()
874
+ .optional()
875
+ .default(true)
876
+ .describe("Hide internal RN components (RCTView, RNS*, Animated, etc.) for cleaner output (default: true)"),
877
+ format: z
878
+ .enum(["json", "tonl"])
879
+ .optional()
880
+ .default("tonl")
881
+ .describe("Output format: 'json' or 'tonl' (default, compact indented tree). Ignored if structureOnly=true.")
882
+ }
883
+ }, async ({ focusedOnly, structureOnly, maxDepth, includeProps, includeStyles, hideInternals, format }) => {
884
+ const result = await getComponentTree({
885
+ focusedOnly,
886
+ structureOnly,
887
+ maxDepth,
888
+ includeProps,
889
+ includeStyles,
890
+ hideInternals,
891
+ format
892
+ });
893
+ if (!result.success) {
894
+ return {
895
+ content: [
896
+ {
897
+ type: "text",
898
+ text: `Error: ${result.error}`
899
+ }
900
+ ],
901
+ isError: true
902
+ };
903
+ }
904
+ return {
905
+ content: [
906
+ {
907
+ type: "text",
908
+ text: `React Component Tree:\n\n${result.result}`
909
+ }
910
+ ]
911
+ };
912
+ });
913
+ // Tool: Get full screen layout (all components with layout styles)
914
+ registerToolWithTelemetry("get_screen_layout", {
915
+ description: "Get layout information for all components on screen. **USE AFTER get_component_tree**: First use get_component_tree(structureOnly=true) to understand structure, then use this tool OR find_components with includeLayout=true to get layout details for specific areas. This tool returns full layout data which can be large for complex screens.",
916
+ inputSchema: {
917
+ maxDepth: z
918
+ .number()
919
+ .optional()
920
+ .default(65)
921
+ .describe("Maximum tree depth to traverse (default: 65, balanced for most screens)"),
922
+ componentsOnly: z
923
+ .boolean()
924
+ .optional()
925
+ .default(false)
926
+ .describe("Only show custom components, hide host components (View, Text, etc.)"),
927
+ shortPath: z
928
+ .boolean()
929
+ .optional()
930
+ .default(true)
931
+ .describe("Show only last 3 path segments instead of full path (default: true)"),
932
+ summary: z
933
+ .boolean()
934
+ .optional()
935
+ .default(false)
936
+ .describe("Return only component counts by name instead of full element list (default: false)"),
937
+ format: z
938
+ .enum(["json", "tonl"])
939
+ .optional()
940
+ .default("tonl")
941
+ .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)")
942
+ }
943
+ }, async ({ maxDepth, componentsOnly, shortPath, summary, format }) => {
944
+ const result = await getScreenLayout({ maxDepth, componentsOnly, shortPath, summary, format });
945
+ if (!result.success) {
946
+ return {
947
+ content: [
948
+ {
949
+ type: "text",
950
+ text: `Error: ${result.error}`
951
+ }
952
+ ],
953
+ isError: true
954
+ };
955
+ }
956
+ return {
957
+ content: [
958
+ {
959
+ type: "text",
960
+ text: `Screen Layout:\n\n${result.result}`
961
+ }
962
+ ]
963
+ };
964
+ });
965
+ // Tool: Inspect a specific component by name
966
+ registerToolWithTelemetry("inspect_component", {
967
+ description: "Inspect a specific React component by name. **DRILL-DOWN TOOL**: Use after get_component_tree(structureOnly=true) to inspect specific components. Returns props, style, state (hooks), and optionally children tree. Use childrenDepth to control how deep nested children go.",
968
+ inputSchema: {
969
+ componentName: z
970
+ .string()
971
+ .describe("Name of the component to inspect (e.g., 'Button', 'HomeScreen', 'FlatList')"),
972
+ index: z
973
+ .number()
974
+ .optional()
975
+ .default(0)
976
+ .describe("If multiple instances exist, which one to inspect (0-based index, default: 0)"),
977
+ includeState: z
978
+ .boolean()
979
+ .optional()
980
+ .default(true)
981
+ .describe("Include component state/hooks (default: true)"),
982
+ includeChildren: z.boolean().optional().default(false).describe("Include children component tree"),
983
+ childrenDepth: z
984
+ .number()
985
+ .optional()
986
+ .default(1)
987
+ .describe("How many levels deep to show children (default: 1 = direct children only, 2+ = nested tree)"),
988
+ shortPath: z.boolean().optional().default(true).describe("Show only last 3 path segments (default: true)"),
989
+ simplifyHooks: z
990
+ .boolean()
991
+ .optional()
992
+ .default(true)
993
+ .describe("Simplify hooks output by hiding effects and reducing depth (default: true)")
994
+ }
995
+ }, async ({ componentName, index, includeState, includeChildren, childrenDepth, shortPath, simplifyHooks }) => {
996
+ const result = await inspectComponent(componentName, {
997
+ index,
998
+ includeState,
999
+ includeChildren,
1000
+ childrenDepth,
1001
+ shortPath,
1002
+ simplifyHooks
1003
+ });
1004
+ if (!result.success) {
1005
+ return {
1006
+ content: [
1007
+ {
1008
+ type: "text",
1009
+ text: `Error: ${result.error}`
1010
+ }
1011
+ ],
1012
+ isError: true
1013
+ };
1014
+ }
1015
+ return {
1016
+ content: [
1017
+ {
1018
+ type: "text",
1019
+ text: `Component Inspection: ${componentName}\n\n${result.result}`
1020
+ }
1021
+ ]
1022
+ };
1023
+ });
1024
+ // Tool: Find components matching a pattern
1025
+ registerToolWithTelemetry("find_components", {
1026
+ description: "Find components matching a name pattern. **TARGETED SEARCH**: Use after get_component_tree(structureOnly=true) to find specific components by pattern and get their layout info. More efficient than get_screen_layout for targeted queries. Use includeLayout=true to get padding/margin/flex styles.",
1027
+ inputSchema: {
1028
+ pattern: z
1029
+ .string()
1030
+ .describe("Regex pattern to match component names (case-insensitive). Examples: 'Button', 'Screen$', 'List.*Item'"),
1031
+ maxResults: z.number().optional().default(20).describe("Maximum number of results to return (default: 20)"),
1032
+ includeLayout: z
1033
+ .boolean()
1034
+ .optional()
1035
+ .default(false)
1036
+ .describe("Include layout styles (padding, margin, flex) for each matched component"),
1037
+ shortPath: z.boolean().optional().default(true).describe("Show only last 3 path segments (default: true)"),
1038
+ summary: z
1039
+ .boolean()
1040
+ .optional()
1041
+ .default(false)
1042
+ .describe("Return only component counts by name instead of full list (default: false)"),
1043
+ format: z
1044
+ .enum(["json", "tonl"])
1045
+ .optional()
1046
+ .default("tonl")
1047
+ .describe("Output format: 'json' or 'tonl' (default, pipe-delimited rows, ~40% smaller)")
1048
+ }
1049
+ }, async ({ pattern, maxResults, includeLayout, shortPath, summary, format }) => {
1050
+ const result = await findComponents(pattern, { maxResults, includeLayout, shortPath, summary, format });
1051
+ if (!result.success) {
1052
+ return {
1053
+ content: [
1054
+ {
1055
+ type: "text",
1056
+ text: `Error: ${result.error}`
1057
+ }
1058
+ ],
1059
+ isError: true
1060
+ };
1061
+ }
1062
+ return {
1063
+ content: [
1064
+ {
1065
+ type: "text",
1066
+ text: `Find Components (pattern: "${pattern}"):\n\n${result.result}`
1067
+ }
1068
+ ]
1069
+ };
1070
+ });
1071
+ // Tool: Unified tap — tries fiber, accessibility, OCR, coordinate strategies
1072
+ registerToolWithTelemetry("tap", {
1073
+ description: "Tap a UI element. Automatically tries multiple strategies: fiber tree (React), accessibility tree (native), and OCR (visual). " +
1074
+ "Auto-detects platform (iOS/Android). For coordinates, accepts pixels from screenshot and converts internally.\n\n" +
1075
+ "Examples:\n" +
1076
+ "- tap(text=\"Submit\") — finds and taps element with matching text\n" +
1077
+ "- tap(testID=\"login-btn\") — finds by testID\n" +
1078
+ "- tap(component=\"HamburgerIcon\") — finds by React component name\n" +
1079
+ "- tap(x=300, y=600) — taps at pixel coordinates from screenshot\n" +
1080
+ "- tap(text=\"Menu\", strategy=\"ocr\") — forces OCR strategy only",
1081
+ inputSchema: {
1082
+ text: z
1083
+ .string()
1084
+ .optional()
1085
+ .describe("Visible text to match (case-insensitive substring). ASCII only for fiber strategy; OCR handles non-ASCII."),
1086
+ testID: z
1087
+ .string()
1088
+ .optional()
1089
+ .describe("Exact match on the element's testID prop."),
1090
+ component: z
1091
+ .string()
1092
+ .optional()
1093
+ .describe("Component name match (case-insensitive substring, e.g. 'Button', 'MenuItem')."),
1094
+ index: z.coerce
1095
+ .number()
1096
+ .optional()
1097
+ .describe("Zero-based index when multiple elements match (default: 0)."),
1098
+ x: z.coerce
1099
+ .number()
1100
+ .optional()
1101
+ .describe("X coordinate in pixels (from screenshot). Must provide both x and y."),
1102
+ y: z.coerce
1103
+ .number()
1104
+ .optional()
1105
+ .describe("Y coordinate in pixels (from screenshot). Must provide both x and y."),
1106
+ strategy: z
1107
+ .enum(["auto", "fiber", "accessibility", "ocr", "coordinate"])
1108
+ .optional()
1109
+ .default("auto")
1110
+ .describe('"auto" (default) tries fiber -> accessibility -> OCR. Set explicitly to skip strategies you know will fail.'),
1111
+ maxTraversalDepth: z.coerce
1112
+ .number()
1113
+ .optional()
1114
+ .describe("Max parent levels to traverse when searching by component name (default: 15). " +
1115
+ "Increase if your component is deeply wrapped (e.g. inside multiple HOCs/animation wrappers)."),
1116
+ },
1117
+ }, async (args) => {
1118
+ const result = await tap({
1119
+ text: args.text,
1120
+ testID: args.testID,
1121
+ component: args.component,
1122
+ index: args.index,
1123
+ x: args.x,
1124
+ y: args.y,
1125
+ strategy: args.strategy,
1126
+ maxTraversalDepth: args.maxTraversalDepth,
1127
+ });
1128
+ const text = JSON.stringify(result, null, 2);
1129
+ return {
1130
+ content: [{ type: "text", text }],
1131
+ isError: !result.success,
1132
+ };
1133
+ });
1134
+ // Tool: Toggle Element Inspector programmatically
1135
+ registerToolWithTelemetry("toggle_element_inspector", {
1136
+ description: "Toggle React Native's Element Inspector overlay on/off. Rarely needed directly — get_inspector_selection auto-enables the inspector when called with coordinates. Use this only when you need manual control over the overlay visibility.",
1137
+ inputSchema: {}
1138
+ }, async () => {
1139
+ const result = await toggleElementInspector();
1140
+ if (!result.success) {
1141
+ return {
1142
+ content: [
1143
+ {
1144
+ type: "text",
1145
+ text: `Error: ${result.error}`
1146
+ }
1147
+ ],
1148
+ isError: true
1149
+ };
1150
+ }
1151
+ try {
1152
+ const parsed = JSON.parse(result.result || "{}");
1153
+ if (parsed.error) {
1154
+ return {
1155
+ content: [
1156
+ {
1157
+ type: "text",
1158
+ text: `Failed to toggle Element Inspector: ${parsed.error}`
1159
+ }
1160
+ ],
1161
+ isError: true
1162
+ };
1163
+ }
1164
+ return {
1165
+ content: [
1166
+ {
1167
+ type: "text",
1168
+ text: parsed.message || "Element Inspector toggled successfully"
1169
+ }
1170
+ ]
1171
+ };
1172
+ }
1173
+ catch {
1174
+ return {
1175
+ content: [
1176
+ {
1177
+ type: "text",
1178
+ text: result.result || "Element Inspector toggled"
1179
+ }
1180
+ ]
1181
+ };
1182
+ }
1183
+ });
1184
+ // Tool: Get currently selected element from Element Inspector
1185
+ registerToolWithTelemetry("get_inspector_selection", {
1186
+ description: "Identify the React component at a screen location by reading RN's Element Inspector. Returns a clean component hierarchy with file paths — ideal for finding the real component name (e.g. HomeScreen > SneakerCard > PulseActionButton). If x/y provided: auto-enables inspector, taps at coordinates, returns hierarchy. If no coordinates: returns current selection. WORKFLOW: Use ios_screenshot or ocr_screenshot to visually identify the target element, then call this tool with coordinates to get the component tree.",
1187
+ inputSchema: {
1188
+ x: z
1189
+ .number()
1190
+ .optional()
1191
+ .describe("X coordinate (in points). If provided with y, auto-taps at this location."),
1192
+ y: z
1193
+ .number()
1194
+ .optional()
1195
+ .describe("Y coordinate (in points). If provided with x, auto-taps at this location.")
1196
+ }
1197
+ }, async ({ x, y }) => {
1198
+ // If coordinates provided, do the full flow: enable inspector -> tap -> read
1199
+ if (x !== undefined && y !== undefined) {
1200
+ // Check if inspector is active
1201
+ const inspectorActive = await isInspectorActive();
1202
+ // Enable inspector if not active
1203
+ if (!inspectorActive) {
1204
+ await toggleElementInspector();
1205
+ // Wait for inspector to initialize
1206
+ await new Promise((resolve) => setTimeout(resolve, 300));
1207
+ }
1208
+ // Detect platform from connected app
1209
+ const app = getFirstConnectedApp();
1210
+ if (!app) {
1211
+ return {
1212
+ content: [{ type: "text", text: "No app connected. Run scan_metro first." }],
1213
+ isError: true
1214
+ };
1215
+ }
1216
+ const isIOS = app.deviceInfo.title?.toLowerCase().includes("iphone") ||
1217
+ app.deviceInfo.title?.toLowerCase().includes("ipad") ||
1218
+ app.deviceInfo.deviceName?.toLowerCase().includes("simulator") ||
1219
+ app.deviceInfo.description?.toLowerCase().includes("ios");
1220
+ // Tap at coordinates
1221
+ try {
1222
+ if (isIOS) {
1223
+ await iosTap(x, y, {});
1224
+ }
1225
+ else {
1226
+ await androidTap(x, y);
1227
+ }
1228
+ }
1229
+ catch (tapError) {
1230
+ return {
1231
+ content: [
1232
+ {
1233
+ type: "text",
1234
+ text: `Failed to tap at (${x}, ${y}): ${tapError instanceof Error ? tapError.message : String(tapError)}`
1235
+ }
1236
+ ],
1237
+ isError: true
1238
+ };
1239
+ }
1240
+ // Wait for selection to update
1241
+ await new Promise((resolve) => setTimeout(resolve, 200));
1242
+ }
1243
+ // Read the current selection
1244
+ const result = await getInspectorSelection();
1245
+ if (!result.success) {
1246
+ return {
1247
+ content: [{ type: "text", text: `Error: ${result.error}` }],
1248
+ isError: true
1249
+ };
1250
+ }
1251
+ try {
1252
+ const parsed = JSON.parse(result.result || "{}");
1253
+ if (parsed.error) {
1254
+ const hint = parsed.hint ? `\n\n${parsed.hint}` : "";
1255
+ return {
1256
+ content: [{ type: "text", text: `${parsed.error}${hint}` }],
1257
+ isError: true
1258
+ };
1259
+ }
1260
+ // Format the output nicely
1261
+ let output = `Element: ${parsed.element}\n`;
1262
+ output += `Path: ${parsed.path}\n`;
1263
+ if (parsed.frame) {
1264
+ output += `Frame: (${parsed.frame.left?.toFixed(1)}, ${parsed.frame.top?.toFixed(1)}) ${parsed.frame.width}x${parsed.frame.height}\n`;
1265
+ }
1266
+ if (parsed.style) {
1267
+ output += `Style: ${JSON.stringify(parsed.style, null, 2)}\n`;
1268
+ }
1269
+ return {
1270
+ content: [{ type: "text", text: output }]
1271
+ };
1272
+ }
1273
+ catch {
1274
+ return {
1275
+ content: [{ type: "text", text: result.result || "No selection data" }]
1276
+ };
1277
+ }
1278
+ });
1279
+ // Tool: Inspect component at coordinates (like Element Inspector)
1280
+ registerToolWithTelemetry("inspect_at_point", {
1281
+ description: "Inspect the React component at specific (x, y) coordinates for layout debugging. Returns component props, measured frame (position/size in dp), and component path. Works on both Paper and Fabric. Coordinates are in dp (density-independent pixels). To convert from screenshot pixels: divide by the device pixel ratio (e.g. 540px / 2.625 = 205dp). Best for: checking layout bounds, reading component props/styles, pixel-perfect debugging. For identifying component names: prefer get_inspector_selection which returns a cleaner hierarchy with file paths.",
1282
+ inputSchema: {
1283
+ x: z
1284
+ .number()
1285
+ .describe("X coordinate in dp (logical pixels). Convert from screenshot pixels by dividing by the device pixel ratio."),
1286
+ y: z
1287
+ .number()
1288
+ .describe("Y coordinate in dp (logical pixels). Convert from screenshot pixels by dividing by the device pixel ratio."),
1289
+ includeProps: z
1290
+ .boolean()
1291
+ .optional()
1292
+ .default(true)
1293
+ .describe("Include component props in the output (default: true)"),
1294
+ includeFrame: z
1295
+ .boolean()
1296
+ .optional()
1297
+ .default(true)
1298
+ .describe("Include position/dimensions (frame) in the output (default: true)")
1299
+ }
1300
+ }, async ({ x, y, includeProps, includeFrame }) => {
1301
+ const result = await inspectAtPoint(x, y, { includeProps, includeFrame });
1302
+ if (!result.success) {
1303
+ return {
1304
+ content: [
1305
+ {
1306
+ type: "text",
1307
+ text: `Error: ${result.error}`
1308
+ }
1309
+ ],
1310
+ isError: true
1311
+ };
1312
+ }
1313
+ // Parse the result to check for errors in the response
1314
+ try {
1315
+ const parsed = JSON.parse(result.result || "{}");
1316
+ if (parsed.error) {
1317
+ const hint = parsed.hint ? `\n\n${parsed.hint}` : "";
1318
+ const alternatives = parsed.alternatives
1319
+ ? `\n\nAlternatives:\n${parsed.alternatives.map((a) => ` - ${a}`).join("\n")}`
1320
+ : "";
1321
+ return {
1322
+ content: [
1323
+ {
1324
+ type: "text",
1325
+ text: `Inspect at (${x}, ${y}): ${parsed.error}${hint}${alternatives}`
1326
+ }
1327
+ ],
1328
+ isError: true
1329
+ };
1330
+ }
1331
+ }
1332
+ catch {
1333
+ // If parsing fails, just return the raw result
1334
+ }
1335
+ return {
1336
+ content: [
1337
+ {
1338
+ type: "text",
1339
+ text: `Element at (${x}, ${y}):\n\n${result.result}`
1340
+ }
1341
+ ]
1342
+ };
1343
+ });
1344
+ // Tool: Get network requests
1345
+ registerToolWithTelemetry("get_network_requests", {
1346
+ description: "Retrieve captured network requests from connected React Native app. Shows URL, method, status, and timing. Tip: Use summary=true first for stats overview (counts by method, status, domain), then fetch specific requests as needed.",
1347
+ inputSchema: {
1348
+ maxRequests: z
1349
+ .number()
1350
+ .optional()
1351
+ .default(50)
1352
+ .describe("Maximum number of requests to return (default: 50)"),
1353
+ method: z.string().optional().describe("Filter by HTTP method (GET, POST, PUT, DELETE, etc.)"),
1354
+ urlPattern: z.string().optional().describe("Filter by URL pattern (case-insensitive substring match)"),
1355
+ status: z.number().optional().describe("Filter by HTTP status code (e.g., 200, 401, 500)"),
1356
+ format: z
1357
+ .enum(["text", "tonl"])
1358
+ .optional()
1359
+ .default("tonl")
1360
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format, ~30-50% smaller)"),
1361
+ summary: z
1362
+ .boolean()
1363
+ .optional()
1364
+ .default(false)
1365
+ .describe("Return statistics only (count, methods, domains, status codes). Use for quick overview.")
1366
+ }
1367
+ }, async ({ maxRequests, method, urlPattern, status, format, summary }) => {
1368
+ // Return summary if requested
1369
+ if (summary) {
1370
+ const stats = getNetworkStats(networkBuffer);
1371
+ let connectionWarning = "";
1372
+ if (networkBuffer.size === 0) {
1373
+ const connStatus = await checkAndEnsureConnection();
1374
+ connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
1375
+ }
1376
+ return {
1377
+ content: [
1378
+ {
1379
+ type: "text",
1380
+ text: `Network Summary:\n\n${stats}${connectionWarning}`
1381
+ }
1382
+ ]
1383
+ };
1384
+ }
1385
+ const { requests, count, formatted } = getNetworkRequests(networkBuffer, {
1386
+ maxRequests,
1387
+ method,
1388
+ urlPattern,
1389
+ status
1390
+ });
1391
+ // Check connection health
1392
+ let connectionWarning = "";
1393
+ if (count === 0) {
1394
+ const connStatus = await checkAndEnsureConnection();
1395
+ connectionWarning = connStatus.message ? `\n\n${connStatus.message}` : "";
1396
+ }
1397
+ else {
1398
+ const passive = getPassiveConnectionStatus();
1399
+ connectionWarning = !passive.connected
1400
+ ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
1401
+ : "";
1402
+ }
1403
+ // Check for recent connection gaps
1404
+ const warningThresholdMs = 30000; // 30 seconds
1405
+ const recentGaps = getRecentGaps(warningThresholdMs);
1406
+ let gapWarning = "";
1407
+ if (recentGaps.length > 0) {
1408
+ const latestGap = recentGaps[recentGaps.length - 1];
1409
+ const gapDuration = latestGap.durationMs || Date.now() - latestGap.disconnectedAt.getTime();
1410
+ if (latestGap.reconnectedAt) {
1411
+ const secAgo = Math.round((Date.now() - latestGap.reconnectedAt.getTime()) / 1000);
1412
+ gapWarning = `\n\n[WARNING] Connection was restored ${secAgo}s ago. Some requests may have been missed during the ${formatDuration(gapDuration)} gap.`;
1413
+ }
1414
+ else {
1415
+ gapWarning = `\n\n[WARNING] Connection is currently disconnected. Network data may be incomplete.`;
1416
+ }
1417
+ }
1418
+ // Use TONL format if requested
1419
+ if (format === "tonl") {
1420
+ const tonlOutput = formatNetworkAsTonl(requests);
1421
+ return {
1422
+ content: [
1423
+ {
1424
+ type: "text",
1425
+ text: `Network Requests (${count} entries):\n\n${tonlOutput}${gapWarning}${connectionWarning}`
1426
+ }
1427
+ ]
1428
+ };
1429
+ }
1430
+ return {
1431
+ content: [
1432
+ {
1433
+ type: "text",
1434
+ text: `Network Requests (${count} entries):\n\n${formatted}${gapWarning}${connectionWarning}`
1435
+ }
1436
+ ]
1437
+ };
1438
+ });
1439
+ // Tool: Search network requests
1440
+ registerToolWithTelemetry("search_network", {
1441
+ description: "Search network requests by URL pattern (case-insensitive)",
1442
+ inputSchema: {
1443
+ urlPattern: z.string().describe("URL pattern to search for"),
1444
+ maxResults: z.number().optional().default(50).describe("Maximum number of results to return (default: 50)"),
1445
+ format: z
1446
+ .enum(["text", "tonl"])
1447
+ .optional()
1448
+ .default("tonl")
1449
+ .describe("Output format: 'text' or 'tonl' (default, compact token-optimized format)")
1450
+ }
1451
+ }, async ({ urlPattern, maxResults, format }) => {
1452
+ const { requests, count, formatted } = searchNetworkRequests(networkBuffer, urlPattern, maxResults);
1453
+ // Check connection health
1454
+ let connectionWarning = "";
1455
+ if (count === 0) {
1456
+ const status = await checkAndEnsureConnection();
1457
+ connectionWarning = status.message ? `\n\n${status.message}` : "";
1458
+ }
1459
+ else {
1460
+ const passive = getPassiveConnectionStatus();
1461
+ connectionWarning = !passive.connected
1462
+ ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
1463
+ : "";
1464
+ }
1465
+ // Use TONL format if requested
1466
+ if (format === "tonl") {
1467
+ const tonlOutput = formatNetworkAsTonl(requests);
1468
+ return {
1469
+ content: [
1470
+ {
1471
+ type: "text",
1472
+ text: `Network search results for "${urlPattern}" (${count} matches):\n\n${tonlOutput}${connectionWarning}`
1473
+ }
1474
+ ]
1475
+ };
1476
+ }
1477
+ return {
1478
+ content: [
1479
+ {
1480
+ type: "text",
1481
+ text: `Network search results for "${urlPattern}" (${count} matches):\n\n${formatted}${connectionWarning}`
1482
+ }
1483
+ ]
1484
+ };
1485
+ });
1486
+ // Tool: Get request details
1487
+ registerToolWithTelemetry("get_request_details", {
1488
+ description: "Get full details of a specific network request including headers, body, and timing. Use get_network_requests first to find the request ID.",
1489
+ inputSchema: {
1490
+ requestId: z.string().describe("The request ID to get details for"),
1491
+ maxBodyLength: z.coerce
1492
+ .number()
1493
+ .optional()
1494
+ .default(500)
1495
+ .describe("Max characters for request body (default: 500, set to 0 for unlimited). Tip: Large POST bodies (file uploads, base64) can be 10KB+."),
1496
+ verbose: z
1497
+ .boolean()
1498
+ .optional()
1499
+ .default(false)
1500
+ .describe("Disable body truncation. Tip: Use when you need to inspect full JSON payloads.")
1501
+ }
1502
+ }, async ({ requestId, maxBodyLength, verbose }) => {
1503
+ const request = networkBuffer.get(requestId);
1504
+ if (!request) {
1505
+ const status = await checkAndEnsureConnection();
1506
+ const connectionNote = status.message ? `\n\n${status.message}` : "";
1507
+ return {
1508
+ content: [
1509
+ {
1510
+ type: "text",
1511
+ text: `Request not found: ${requestId}${connectionNote}`
1512
+ }
1513
+ ],
1514
+ isError: true
1515
+ };
1516
+ }
1517
+ return {
1518
+ content: [
1519
+ {
1520
+ type: "text",
1521
+ text: formatRequestDetails(request, { maxBodyLength, verbose })
1522
+ }
1523
+ ]
1524
+ };
1525
+ });
1526
+ // Tool: Get network stats
1527
+ registerToolWithTelemetry("get_network_stats", {
1528
+ description: "Get statistics about captured network requests: counts by method, status code, and domain.",
1529
+ inputSchema: {}
1530
+ }, async () => {
1531
+ const stats = getNetworkStats(networkBuffer);
1532
+ // Check connection health
1533
+ let connectionWarning = "";
1534
+ if (networkBuffer.size === 0) {
1535
+ const status = await checkAndEnsureConnection();
1536
+ connectionWarning = status.message ? `\n\n${status.message}` : "";
1537
+ }
1538
+ else {
1539
+ const passive = getPassiveConnectionStatus();
1540
+ connectionWarning = !passive.connected
1541
+ ? "\n\n[CONNECTION] Disconnected. Showing cached data. New data is not being captured."
1542
+ : "";
1543
+ }
1544
+ return {
1545
+ content: [
1546
+ {
1547
+ type: "text",
1548
+ text: `Network Statistics:\n\n${stats}${connectionWarning}`
1549
+ }
1550
+ ]
1551
+ };
1552
+ });
1553
+ // Tool: Clear network requests
1554
+ registerToolWithTelemetry("clear_network", {
1555
+ description: "Clear the network request buffer",
1556
+ inputSchema: {}
1557
+ }, async () => {
1558
+ const count = networkBuffer.clear();
1559
+ return {
1560
+ content: [
1561
+ {
1562
+ type: "text",
1563
+ text: `Cleared ${count} network requests from buffer.`
1564
+ }
1565
+ ]
1566
+ };
1567
+ });
1568
+ // Tool: Reload the app
1569
+ registerToolWithTelemetry("reload_app", {
1570
+ description: "Reload the React Native app (triggers JavaScript bundle reload like pressing 'r' in Metro). Will auto-connect to Metro if no connection exists. Note: After reload, the app may take a few seconds to fully restart and become responsive — wait before running other tools. IMPORTANT: React Native has Fast Refresh enabled by default - code changes are automatically applied without needing reload. Only use when: (1) logs/behavior don't reflect code changes after a few seconds, (2) app is in broken/error state, or (3) need to reset app state completely (navigation stack, context, etc.).",
1571
+ inputSchema: {}
1572
+ }, async () => {
1573
+ const result = await reloadApp();
1574
+ if (!result.success) {
1575
+ return {
1576
+ content: [
1577
+ {
1578
+ type: "text",
1579
+ text: `Error: ${result.error}`
1580
+ }
1581
+ ],
1582
+ isError: true
1583
+ };
1584
+ }
1585
+ return {
1586
+ content: [
1587
+ {
1588
+ type: "text",
1589
+ text: result.result ?? "App reload triggered"
1590
+ }
1591
+ ]
1592
+ };
1593
+ });
1594
+ // ============================================================================
1595
+ // Bundle/Build Error Tools
1596
+ // ============================================================================
1597
+ // Tool: Get bundle status
1598
+ registerToolWithTelemetry("get_bundle_status", {
1599
+ 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.",
1600
+ inputSchema: {}
1601
+ }, async () => {
1602
+ // Get port from first connected app if available
1603
+ const apps = Array.from(connectedApps.values());
1604
+ const metroPort = apps.length > 0 ? apps[0].port : undefined;
1605
+ const { formatted } = await getBundleStatusWithErrors(bundleErrorBuffer, metroPort);
1606
+ return {
1607
+ content: [
1608
+ {
1609
+ type: "text",
1610
+ text: formatted
1611
+ }
1612
+ ]
1613
+ };
1614
+ });
1615
+ // Tool: Get bundle errors
1616
+ registerToolWithTelemetry("get_bundle_errors", {
1617
+ 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.",
1618
+ inputSchema: {
1619
+ maxErrors: z.number().optional().default(10).describe("Maximum number of errors to return (default: 10)"),
1620
+ platform: z
1621
+ .enum(["ios", "android"])
1622
+ .optional()
1623
+ .describe("Platform for screenshot fallback when no errors are captured via CDP. Required to enable fallback."),
1624
+ deviceId: z
1625
+ .string()
1626
+ .optional()
1627
+ .describe("Optional device ID for screenshot fallback. Uses first available device if not specified.")
1628
+ }
1629
+ }, async ({ maxErrors, platform, deviceId }) => {
1630
+ // First, try to get errors from the buffer (captured via CDP/Metro WebSocket)
1631
+ const { errors, formatted } = getBundleErrors(bundleErrorBuffer, { maxErrors });
1632
+ if (errors.length > 0) {
1633
+ return {
1634
+ content: [
1635
+ {
1636
+ type: "text",
1637
+ text: `Bundle Errors (${errors.length} captured):\n\n${formatted}`
1638
+ }
1639
+ ]
1640
+ };
1641
+ }
1642
+ // No errors in buffer - check if we should try fallback
1643
+ if (!platform) {
1644
+ // No platform specified, return empty result with hint
1645
+ return {
1646
+ content: [
1647
+ {
1648
+ type: "text",
1649
+ 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.`
1650
+ }
1651
+ ]
1652
+ };
1653
+ }
1654
+ // Check Metro state to see if fallback is warranted
1655
+ const metroState = await checkMetroState(connectedApps.size);
1656
+ if (!metroState.needsFallback) {
1657
+ // Metro not running or apps are connected - fallback not needed
1658
+ const statusMsg = metroState.metroRunning
1659
+ ? "Metro is running and apps are connected."
1660
+ : "Metro is not running.";
1661
+ return {
1662
+ content: [
1663
+ {
1664
+ type: "text",
1665
+ text: `Bundle Errors (0 captured):\n\nNo bundle errors captured. ${statusMsg}`
1666
+ }
1667
+ ]
1668
+ };
1669
+ }
1670
+ // Metro is running but no apps connected - try screenshot fallback
1671
+ try {
1672
+ let screenshotResult;
1673
+ if (platform === "android") {
1674
+ screenshotResult = await androidScreenshot(undefined, deviceId);
1675
+ }
1676
+ else {
1677
+ screenshotResult = await iosScreenshot(undefined, deviceId);
1678
+ }
1679
+ if (!screenshotResult.success || !screenshotResult.data) {
1680
+ return {
1681
+ content: [
1682
+ {
1683
+ type: "text",
1684
+ 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"}`
1685
+ }
1686
+ ]
1687
+ };
1688
+ }
1689
+ // Calculate device pixel ratio for iOS
1690
+ const devicePixelRatio = platform === "ios" && screenshotResult.originalWidth && screenshotResult.originalHeight
1691
+ ? inferIOSDevicePixelRatio(screenshotResult.originalWidth, screenshotResult.originalHeight)
1692
+ : 1;
1693
+ // Run OCR on the screenshot
1694
+ const ocrResult = await recognizeText(screenshotResult.data, {
1695
+ scaleFactor: screenshotResult.scaleFactor || 1,
1696
+ platform,
1697
+ devicePixelRatio
1698
+ });
1699
+ if (!ocrResult.success || !ocrResult.fullText) {
1700
+ return {
1701
+ content: [
1702
+ {
1703
+ type: "text",
1704
+ 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.`
1705
+ }
1706
+ ]
1707
+ };
1708
+ }
1709
+ // Parse the OCR text for error information
1710
+ const parsedError = parseErrorScreenText(ocrResult.fullText);
1711
+ if (!parsedError.found || !parsedError.error) {
1712
+ return {
1713
+ content: [
1714
+ {
1715
+ type: "text",
1716
+ 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)`
1717
+ }
1718
+ ]
1719
+ };
1720
+ }
1721
+ // Add the parsed error to the buffer for future reference
1722
+ bundleErrorBuffer.add(parsedError.error);
1723
+ return {
1724
+ content: [
1725
+ {
1726
+ type: "text",
1727
+ text: `Bundle Errors (1 captured via screenshot fallback):\n\n${formatParsedError(parsedError)}`
1728
+ }
1729
+ ]
1730
+ };
1731
+ }
1732
+ catch (fallbackError) {
1733
+ return {
1734
+ content: [
1735
+ {
1736
+ type: "text",
1737
+ 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)}`
1738
+ }
1739
+ ]
1740
+ };
1741
+ }
1742
+ });
1743
+ // Tool: Clear bundle errors
1744
+ registerToolWithTelemetry("clear_bundle_errors", {
1745
+ description: "Clear the bundle error buffer",
1746
+ inputSchema: {}
1747
+ }, async () => {
1748
+ const count = bundleErrorBuffer.clear();
1749
+ return {
1750
+ content: [
1751
+ {
1752
+ type: "text",
1753
+ text: `Cleared ${count} bundle errors from buffer.`
1754
+ }
1755
+ ]
1756
+ };
1757
+ });
1758
+ // ============================================================================
1759
+ // Android Tools
1760
+ // ============================================================================
1761
+ // Tool: List Android devices
1762
+ registerToolWithTelemetry("list_android_devices", {
1763
+ description: "List connected Android devices and emulators via ADB",
1764
+ inputSchema: {}
1765
+ }, async () => {
1766
+ const result = await listAndroidDevices();
1767
+ return {
1768
+ content: [
1769
+ {
1770
+ type: "text",
1771
+ text: result.success ? result.result : `Error: ${result.error}`
1772
+ }
1773
+ ],
1774
+ isError: !result.success
1775
+ };
1776
+ });
1777
+ // Tool: Android screenshot
1778
+ registerToolWithTelemetry("android_screenshot", {
1779
+ description: "Take a screenshot from an Android device/emulator. Returns the image data that can be displayed.",
1780
+ inputSchema: {
1781
+ outputPath: z
1782
+ .string()
1783
+ .optional()
1784
+ .describe("Optional path to save the screenshot. If not provided, saves to temp directory."),
1785
+ deviceId: z
1786
+ .string()
1787
+ .optional()
1788
+ .describe("Optional device ID (from list_android_devices). Uses first available device if not specified.")
1789
+ }
1790
+ }, async ({ outputPath, deviceId }) => {
1791
+ const result = await androidScreenshot(outputPath, deviceId);
1792
+ if (!result.success) {
1793
+ return {
1794
+ content: [
1795
+ {
1796
+ type: "text",
1797
+ text: `Error: ${result.error}`
1798
+ }
1799
+ ],
1800
+ isError: true
1801
+ };
1802
+ }
1803
+ // Include image data if available
1804
+ if (result.data) {
1805
+ // Build info text with coordinate conversion guidance
1806
+ const pixelWidth = result.originalWidth || 0;
1807
+ const pixelHeight = result.originalHeight || 0;
1808
+ // Store screenshot metadata for coordinate conversion
1809
+ const firstApp = connectedApps.values().next().value;
1810
+ if (firstApp) {
1811
+ firstApp.lastScreenshot = {
1812
+ originalWidth: pixelWidth,
1813
+ originalHeight: pixelHeight,
1814
+ scaleFactor: result.scaleFactor || 1,
1815
+ };
1816
+ }
1817
+ let infoText = `Screenshot captured (${pixelWidth}x${pixelHeight} pixels)`;
1818
+ // Get status bar height for coordinate guidance
1819
+ let statusBarPixels = 63; // Default fallback
1820
+ let statusBarDp = 24;
1821
+ let densityDpi = 440; // Common default
1822
+ try {
1823
+ const statusBarResult = await androidGetStatusBarHeight(deviceId);
1824
+ if (statusBarResult.success && statusBarResult.heightPixels) {
1825
+ statusBarPixels = statusBarResult.heightPixels;
1826
+ statusBarDp = statusBarResult.heightDp || 24;
1827
+ }
1828
+ const densityResult = await androidGetDensity(deviceId);
1829
+ if (densityResult.success && densityResult.density) {
1830
+ densityDpi = densityResult.density;
1831
+ }
1832
+ }
1833
+ catch {
1834
+ // Use defaults
1835
+ }
1836
+ infoText += `\n📱 Android uses PIXELS for tap coordinates (same as screenshot)`;
1837
+ if (result.scaleFactor && result.scaleFactor > 1) {
1838
+ infoText += `\n⚠️ Image was scaled down to fit API limits. Scale factor: ${result.scaleFactor.toFixed(3)}`;
1839
+ infoText += `\n📐 To convert image coords to tap coords: multiply by ${result.scaleFactor.toFixed(3)}`;
1840
+ }
1841
+ else {
1842
+ infoText += `\n📐 Screenshot coords = tap coords (no conversion needed)`;
1843
+ }
1844
+ infoText += `\n⚠️ Status bar: ${statusBarPixels}px (${statusBarDp}dp) from top - app content starts below this`;
1845
+ infoText += `\n📊 Display density: ${densityDpi}dpi`;
1846
+ return {
1847
+ content: [
1848
+ {
1849
+ type: "text",
1850
+ text: infoText
1851
+ },
1852
+ {
1853
+ type: "image",
1854
+ data: result.data.toString("base64"),
1855
+ mimeType: "image/jpeg"
1856
+ }
1857
+ ]
1858
+ };
1859
+ }
1860
+ return {
1861
+ content: [
1862
+ {
1863
+ type: "text",
1864
+ text: `Screenshot saved to: ${result.result}`
1865
+ }
1866
+ ]
1867
+ };
1868
+ });
1869
+ // Tool: Android install app
1870
+ registerToolWithTelemetry("android_install_app", {
1871
+ description: "Install an APK on an Android device/emulator",
1872
+ inputSchema: {
1873
+ apkPath: z.string().describe("Path to the APK file to install"),
1874
+ deviceId: z
1875
+ .string()
1876
+ .optional()
1877
+ .describe("Optional device ID. Uses first available device if not specified."),
1878
+ replace: z
1879
+ .boolean()
1880
+ .optional()
1881
+ .default(true)
1882
+ .describe("Replace existing app if already installed (default: true)"),
1883
+ grantPermissions: z
1884
+ .boolean()
1885
+ .optional()
1886
+ .default(false)
1887
+ .describe("Grant all runtime permissions on install (default: false)")
1888
+ }
1889
+ }, async ({ apkPath, deviceId, replace, grantPermissions }) => {
1890
+ const result = await androidInstallApp(apkPath, deviceId, { replace, grantPermissions });
1891
+ return {
1892
+ content: [
1893
+ {
1894
+ type: "text",
1895
+ text: result.success ? result.result : `Error: ${result.error}`
1896
+ }
1897
+ ],
1898
+ isError: !result.success
1899
+ };
1900
+ });
1901
+ // Tool: Android launch app
1902
+ registerToolWithTelemetry("android_launch_app", {
1903
+ description: "Launch an app on an Android device/emulator by package name",
1904
+ inputSchema: {
1905
+ packageName: z.string().describe("Package name of the app (e.g., com.example.myapp)"),
1906
+ activityName: z
1907
+ .string()
1908
+ .optional()
1909
+ .describe("Optional activity name to launch (e.g., .MainActivity). If not provided, launches the main activity."),
1910
+ deviceId: z
1911
+ .string()
1912
+ .optional()
1913
+ .describe("Optional device ID. Uses first available device if not specified.")
1914
+ }
1915
+ }, async ({ packageName, activityName, deviceId }) => {
1916
+ const result = await androidLaunchApp(packageName, activityName, deviceId);
1917
+ return {
1918
+ content: [
1919
+ {
1920
+ type: "text",
1921
+ text: result.success ? result.result : `Error: ${result.error}`
1922
+ }
1923
+ ],
1924
+ isError: !result.success
1925
+ };
1926
+ });
1927
+ // Tool: Android list packages
1928
+ registerToolWithTelemetry("android_list_packages", {
1929
+ description: "List installed packages on an Android device/emulator",
1930
+ inputSchema: {
1931
+ deviceId: z
1932
+ .string()
1933
+ .optional()
1934
+ .describe("Optional device ID. Uses first available device if not specified."),
1935
+ filter: z.string().optional().describe("Optional filter to search packages by name (case-insensitive)")
1936
+ }
1937
+ }, async ({ deviceId, filter }) => {
1938
+ const result = await androidListPackages(deviceId, filter);
1939
+ return {
1940
+ content: [
1941
+ {
1942
+ type: "text",
1943
+ text: result.success ? result.result : `Error: ${result.error}`
1944
+ }
1945
+ ],
1946
+ isError: !result.success
1947
+ };
1948
+ });
1949
+ // ============================================================================
1950
+ // Android UI Input Tools (Phase 2)
1951
+ // ============================================================================
1952
+ // Tool: Android long press
1953
+ registerToolWithTelemetry("android_long_press", {
1954
+ description: "Long press at specific coordinates on an Android device/emulator screen",
1955
+ inputSchema: {
1956
+ x: z.coerce.number().describe("X coordinate in pixels"),
1957
+ y: z.coerce.number().describe("Y coordinate in pixels"),
1958
+ durationMs: z.number().optional().default(1000).describe("Press duration in milliseconds (default: 1000)"),
1959
+ deviceId: z
1960
+ .string()
1961
+ .optional()
1962
+ .describe("Optional device ID. Uses first available device if not specified.")
1963
+ }
1964
+ }, async ({ x, y, durationMs, deviceId }) => {
1965
+ const result = await androidLongPress(x, y, durationMs, deviceId);
1966
+ return {
1967
+ content: [
1968
+ {
1969
+ type: "text",
1970
+ text: result.success ? result.result : `Error: ${result.error}`
1971
+ }
1972
+ ],
1973
+ isError: !result.success
1974
+ };
1975
+ });
1976
+ // Tool: Android swipe
1977
+ registerToolWithTelemetry("android_swipe", {
1978
+ description: "Swipe from one point to another on an Android device/emulator screen",
1979
+ inputSchema: {
1980
+ startX: z.coerce.number().describe("Starting X coordinate in pixels"),
1981
+ startY: z.coerce.number().describe("Starting Y coordinate in pixels"),
1982
+ endX: z.coerce.number().describe("Ending X coordinate in pixels"),
1983
+ endY: z.coerce.number().describe("Ending Y coordinate in pixels"),
1984
+ durationMs: z.number().optional().default(300).describe("Swipe duration in milliseconds (default: 300)"),
1985
+ deviceId: z
1986
+ .string()
1987
+ .optional()
1988
+ .describe("Optional device ID. Uses first available device if not specified.")
1989
+ }
1990
+ }, async ({ startX, startY, endX, endY, durationMs, deviceId }) => {
1991
+ const result = await androidSwipe(startX, startY, endX, endY, durationMs, deviceId);
1992
+ return {
1993
+ content: [
1994
+ {
1995
+ type: "text",
1996
+ text: result.success ? result.result : `Error: ${result.error}`
1997
+ }
1998
+ ],
1999
+ isError: !result.success
2000
+ };
2001
+ });
2002
+ // Tool: Android input text
2003
+ registerToolWithTelemetry("android_input_text", {
2004
+ description: "Type text on an Android device/emulator. The text will be input at the current focus point (tap an input field first).",
2005
+ inputSchema: {
2006
+ text: z.string().describe("Text to type"),
2007
+ deviceId: z
2008
+ .string()
2009
+ .optional()
2010
+ .describe("Optional device ID. Uses first available device if not specified.")
2011
+ }
2012
+ }, async ({ text, deviceId }) => {
2013
+ const result = await androidInputText(text, deviceId);
2014
+ return {
2015
+ content: [
2016
+ {
2017
+ type: "text",
2018
+ text: result.success ? result.result : `Error: ${result.error}`
2019
+ }
2020
+ ],
2021
+ isError: !result.success
2022
+ };
2023
+ });
2024
+ // Tool: Android key event
2025
+ registerToolWithTelemetry("android_key_event", {
2026
+ description: `Send a key event to an Android device/emulator. Common keys: ${Object.keys(ANDROID_KEY_EVENTS).join(", ")}`,
2027
+ inputSchema: {
2028
+ key: z.string().describe(`Key name (${Object.keys(ANDROID_KEY_EVENTS).join(", ")}) or numeric keycode`),
2029
+ deviceId: z
2030
+ .string()
2031
+ .optional()
2032
+ .describe("Optional device ID. Uses first available device if not specified.")
2033
+ }
2034
+ }, async ({ key, deviceId }) => {
2035
+ // Try to parse as number first, otherwise treat as key name
2036
+ const keyCode = /^\d+$/.test(key) ? parseInt(key, 10) : key.toUpperCase();
2037
+ const result = await androidKeyEvent(keyCode, deviceId);
2038
+ return {
2039
+ content: [
2040
+ {
2041
+ type: "text",
2042
+ text: result.success ? result.result : `Error: ${result.error}`
2043
+ }
2044
+ ],
2045
+ isError: !result.success
2046
+ };
2047
+ });
2048
+ // Tool: Android get screen size
2049
+ registerToolWithTelemetry("android_get_screen_size", {
2050
+ description: "Get the screen size (resolution) of an Android device/emulator",
2051
+ inputSchema: {
2052
+ deviceId: z
2053
+ .string()
2054
+ .optional()
2055
+ .describe("Optional device ID. Uses first available device if not specified.")
2056
+ }
2057
+ }, async ({ deviceId }) => {
2058
+ const result = await androidGetScreenSize(deviceId);
2059
+ if (!result.success) {
2060
+ return {
2061
+ content: [
2062
+ {
2063
+ type: "text",
2064
+ text: `Error: ${result.error}`
2065
+ }
2066
+ ],
2067
+ isError: true
2068
+ };
2069
+ }
2070
+ return {
2071
+ content: [
2072
+ {
2073
+ type: "text",
2074
+ text: `Screen size: ${result.width}x${result.height} pixels`
2075
+ }
2076
+ ]
2077
+ };
2078
+ });
2079
+ // ============================================================================
2080
+ // Android Accessibility Tools (UI Hierarchy)
2081
+ // ============================================================================
2082
+ // Tool: Android describe all (UI hierarchy)
2083
+ server.registerTool("android_describe_all", {
2084
+ description: "Get the full UI accessibility tree from the Android device using uiautomator. Returns a hierarchical view of all UI elements with their text, content-description, resource-id, bounds, and tap coordinates.",
2085
+ inputSchema: {
2086
+ deviceId: z
2087
+ .string()
2088
+ .optional()
2089
+ .describe("Optional device ID. Uses first available device if not specified.")
2090
+ }
2091
+ }, async ({ deviceId }) => {
2092
+ const result = await androidDescribeAll(deviceId);
2093
+ return {
2094
+ content: [
2095
+ {
2096
+ type: "text",
2097
+ text: result.success ? result.formatted : `Error: ${result.error}`
2098
+ }
2099
+ ],
2100
+ isError: !result.success
2101
+ };
2102
+ });
2103
+ // Tool: Android describe point
2104
+ server.registerTool("android_describe_point", {
2105
+ description: "Get UI element info at specific coordinates on an Android device. Returns the element's text, content-description, resource-id, bounds, and state flags.",
2106
+ inputSchema: {
2107
+ x: z.coerce.number().describe("X coordinate in pixels"),
2108
+ y: z.coerce.number().describe("Y coordinate in pixels"),
2109
+ deviceId: z
2110
+ .string()
2111
+ .optional()
2112
+ .describe("Optional device ID. Uses first available device if not specified.")
2113
+ }
2114
+ }, async ({ x, y, deviceId }) => {
2115
+ const result = await androidDescribePoint(x, y, deviceId);
2116
+ return {
2117
+ content: [
2118
+ {
2119
+ type: "text",
2120
+ text: result.success ? result.formatted : `Error: ${result.error}`
2121
+ }
2122
+ ],
2123
+ isError: !result.success
2124
+ };
2125
+ });
2126
+ // Tool: Android find element (no screenshot needed)
2127
+ server.registerTool("android_find_element", {
2128
+ description: "Find a UI element on Android screen by text, content description, or resource ID. Returns element details including tap coordinates. Use this to check if an element exists without tapping it. Workflow: 1) wait_for_element, 2) find_element, 3) tap with returned coordinates. Prefer this over screenshots for button taps.",
2129
+ inputSchema: {
2130
+ text: z.string().optional().describe("Exact text match for the element"),
2131
+ textContains: z.string().optional().describe("Partial text match (case-insensitive)"),
2132
+ contentDesc: z.string().optional().describe("Exact content-description match"),
2133
+ contentDescContains: z.string().optional().describe("Partial content-description match (case-insensitive)"),
2134
+ resourceId: z
2135
+ .string()
2136
+ .optional()
2137
+ .describe("Resource ID match (e.g., 'com.app:id/button' or just 'button')"),
2138
+ index: z
2139
+ .number()
2140
+ .optional()
2141
+ .describe("If multiple elements match, select the nth one (0-indexed, default: 0)"),
2142
+ deviceId: z
2143
+ .string()
2144
+ .optional()
2145
+ .describe("Optional device ID. Uses first available device if not specified.")
2146
+ }
2147
+ }, async ({ text, textContains, contentDesc, contentDescContains, resourceId, index, deviceId }) => {
2148
+ const result = await androidFindElement({ text, textContains, contentDesc, contentDescContains, resourceId, index }, deviceId);
2149
+ if (!result.success) {
2150
+ return {
2151
+ content: [{ type: "text", text: `Error: ${result.error}` }],
2152
+ isError: true
2153
+ };
2154
+ }
2155
+ if (!result.found) {
2156
+ return {
2157
+ content: [
2158
+ {
2159
+ type: "text",
2160
+ text: result.error || "Element not found"
2161
+ }
2162
+ ]
2163
+ };
2164
+ }
2165
+ const el = result.element;
2166
+ const info = [
2167
+ `Found element (${result.matchCount} match${result.matchCount > 1 ? "es" : ""})`,
2168
+ ` Text: "${el.text}"`,
2169
+ ` Content-desc: "${el.contentDesc}"`,
2170
+ ` Resource ID: "${el.resourceId}"`,
2171
+ ` Class: ${el.className}`,
2172
+ ` Bounds: [${el.bounds.left},${el.bounds.top}][${el.bounds.right},${el.bounds.bottom}]`,
2173
+ ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
2174
+ ` Clickable: ${el.clickable}, Enabled: ${el.enabled}`
2175
+ ].join("\n");
2176
+ return {
2177
+ content: [{ type: "text", text: info }]
2178
+ };
2179
+ });
2180
+ // Tool: Android wait for element
2181
+ server.registerTool("android_wait_for_element", {
2182
+ description: "Wait for a UI element to appear on Android screen. Polls the accessibility tree until the element is found or timeout is reached. Use this FIRST after navigation to ensure screen is ready, then use find_element + tap.",
2183
+ inputSchema: {
2184
+ text: z.string().optional().describe("Exact text match for the element"),
2185
+ textContains: z.string().optional().describe("Partial text match (case-insensitive)"),
2186
+ contentDesc: z.string().optional().describe("Exact content-description match"),
2187
+ contentDescContains: z.string().optional().describe("Partial content-description match (case-insensitive)"),
2188
+ resourceId: z
2189
+ .string()
2190
+ .optional()
2191
+ .describe("Resource ID match (e.g., 'com.app:id/button' or just 'button')"),
2192
+ index: z
2193
+ .number()
2194
+ .optional()
2195
+ .describe("If multiple elements match, select the nth one (0-indexed, default: 0)"),
2196
+ timeoutMs: z
2197
+ .number()
2198
+ .optional()
2199
+ .default(10000)
2200
+ .describe("Maximum time to wait in milliseconds (default: 10000)"),
2201
+ pollIntervalMs: z
2202
+ .number()
2203
+ .optional()
2204
+ .default(500)
2205
+ .describe("Time between polls in milliseconds (default: 500)"),
2206
+ deviceId: z
2207
+ .string()
2208
+ .optional()
2209
+ .describe("Optional device ID. Uses first available device if not specified.")
2210
+ }
2211
+ }, async ({ text, textContains, contentDesc, contentDescContains, resourceId, index, timeoutMs, pollIntervalMs, deviceId }) => {
2212
+ const result = await androidWaitForElement({
2213
+ text,
2214
+ textContains,
2215
+ contentDesc,
2216
+ contentDescContains,
2217
+ resourceId,
2218
+ index,
2219
+ timeoutMs,
2220
+ pollIntervalMs
2221
+ }, deviceId);
2222
+ if (!result.success) {
2223
+ return {
2224
+ content: [{ type: "text", text: `Error: ${result.error}` }],
2225
+ isError: true
2226
+ };
2227
+ }
2228
+ if (!result.found) {
2229
+ return {
2230
+ content: [
2231
+ {
2232
+ type: "text",
2233
+ text: result.timedOut
2234
+ ? `Timed out after ${result.elapsedMs}ms - element not found`
2235
+ : result.error || "Element not found"
2236
+ }
2237
+ ]
2238
+ };
2239
+ }
2240
+ const el = result.element;
2241
+ const info = [
2242
+ `Element found after ${result.elapsedMs}ms`,
2243
+ ` Text: "${el.text}"`,
2244
+ ` Content-desc: "${el.contentDesc}"`,
2245
+ ` Resource ID: "${el.resourceId}"`,
2246
+ ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
2247
+ ` Clickable: ${el.clickable}, Enabled: ${el.enabled}`
2248
+ ].join("\n");
2249
+ return {
2250
+ content: [{ type: "text", text: info }]
2251
+ };
2252
+ });
2253
+ // ============================================================================
2254
+ // iOS Simulator Tools
2255
+ // ============================================================================
2256
+ // Tool: List iOS simulators
2257
+ registerToolWithTelemetry("list_ios_simulators", {
2258
+ description: "List available iOS simulators",
2259
+ inputSchema: {
2260
+ onlyBooted: z
2261
+ .boolean()
2262
+ .optional()
2263
+ .default(false)
2264
+ .describe("Only show currently running simulators (default: false)")
2265
+ }
2266
+ }, async ({ onlyBooted }) => {
2267
+ const result = await listIOSSimulators(onlyBooted);
2268
+ return {
2269
+ content: [
2270
+ {
2271
+ type: "text",
2272
+ text: result.success ? result.result : `Error: ${result.error}`
2273
+ }
2274
+ ],
2275
+ isError: !result.success
2276
+ };
2277
+ });
2278
+ // Tool: iOS screenshot
2279
+ registerToolWithTelemetry("ios_screenshot", {
2280
+ description: "Take a screenshot from an iOS simulator. Returns the image data that can be displayed.",
2281
+ inputSchema: {
2282
+ outputPath: z
2283
+ .string()
2284
+ .optional()
2285
+ .describe("Optional path to save the screenshot. If not provided, saves to temp directory."),
2286
+ udid: z
2287
+ .string()
2288
+ .optional()
2289
+ .describe("Optional simulator UDID (from list_ios_simulators). Uses booted simulator if not specified.")
2290
+ }
2291
+ }, async ({ outputPath, udid }) => {
2292
+ const result = await iosScreenshot(outputPath, udid);
2293
+ if (!result.success) {
2294
+ return {
2295
+ content: [
2296
+ {
2297
+ type: "text",
2298
+ text: `Error: ${result.error}`
2299
+ }
2300
+ ],
2301
+ isError: true
2302
+ };
2303
+ }
2304
+ // Include image data if available
2305
+ if (result.data) {
2306
+ // Build info text with coordinate guidance for iOS
2307
+ const pixelWidth = result.originalWidth || 0;
2308
+ const pixelHeight = result.originalHeight || 0;
2309
+ // Store screenshot metadata for coordinate conversion
2310
+ const firstApp = connectedApps.values().next().value;
2311
+ if (firstApp) {
2312
+ firstApp.lastScreenshot = {
2313
+ originalWidth: pixelWidth,
2314
+ originalHeight: pixelHeight,
2315
+ scaleFactor: result.scaleFactor || 1,
2316
+ };
2317
+ }
2318
+ // Try to get actual screen dimensions and safe area from accessibility tree
2319
+ let pointWidth = 0;
2320
+ let pointHeight = 0;
2321
+ let scaleFactor = 3; // Default to 3x for modern iPhones
2322
+ let safeAreaTop = 59; // Default safe area offset
2323
+ try {
2324
+ const describeResult = await iosDescribeAll(udid);
2325
+ if (describeResult.success && describeResult.elements && describeResult.elements.length > 0) {
2326
+ // First element is typically the Application with full screen frame
2327
+ const rootElement = describeResult.elements[0];
2328
+ // Try parsed frame first, then parse AXFrame string
2329
+ if (rootElement.frame) {
2330
+ pointWidth = Math.round(rootElement.frame.width);
2331
+ pointHeight = Math.round(rootElement.frame.height);
2332
+ // The frame.y of the root element indicates where content starts (after status bar)
2333
+ if (rootElement.frame.y > 0) {
2334
+ safeAreaTop = Math.round(rootElement.frame.y);
2335
+ }
2336
+ }
2337
+ else if (rootElement.AXFrame) {
2338
+ // Parse format: "{{x, y}, {width, height}}"
2339
+ const match = rootElement.AXFrame.match(/\{\{([\d.]+),\s*([\d.]+)\},\s*\{([\d.]+),\s*([\d.]+)\}\}/);
2340
+ if (match) {
2341
+ const frameY = parseFloat(match[2]);
2342
+ pointWidth = Math.round(parseFloat(match[3]));
2343
+ pointHeight = Math.round(parseFloat(match[4]));
2344
+ if (frameY > 0) {
2345
+ safeAreaTop = Math.round(frameY);
2346
+ }
2347
+ }
2348
+ }
2349
+ // Calculate actual scale factor
2350
+ if (pointWidth > 0) {
2351
+ scaleFactor = Math.round(pixelWidth / pointWidth);
2352
+ }
2353
+ }
2354
+ }
2355
+ catch {
2356
+ // Fallback: use 3x scale for modern devices
2357
+ }
2358
+ // Fallback if we couldn't get dimensions
2359
+ if (pointWidth === 0) {
2360
+ pointWidth = Math.round(pixelWidth / scaleFactor);
2361
+ pointHeight = Math.round(pixelHeight / scaleFactor);
2362
+ }
2363
+ const safeAreaOffsetPixels = safeAreaTop * scaleFactor;
2364
+ let infoText = `Screenshot captured (${pixelWidth}x${pixelHeight} pixels)`;
2365
+ infoText += `\n📱 iOS tap coordinates use POINTS: ${pointWidth}x${pointHeight}`;
2366
+ infoText += `\n📐 To convert screenshot coords to tap points:`;
2367
+ infoText += `\n tap_x = pixel_x / ${scaleFactor}`;
2368
+ infoText += `\n tap_y = pixel_y / ${scaleFactor}`;
2369
+ infoText += `\n⚠️ Status bar + safe area: ${safeAreaTop} points (${safeAreaOffsetPixels} pixels) from top`;
2370
+ if (result.scaleFactor && result.scaleFactor > 1) {
2371
+ infoText += `\n🖼️ Image was scaled down to fit API limits (scale: ${result.scaleFactor.toFixed(3)})`;
2372
+ }
2373
+ infoText += `\n💡 Use ios_describe_all to get exact element coordinates`;
2374
+ return {
2375
+ content: [
2376
+ {
2377
+ type: "text",
2378
+ text: infoText
2379
+ },
2380
+ {
2381
+ type: "image",
2382
+ data: result.data.toString("base64"),
2383
+ mimeType: "image/jpeg"
2384
+ }
2385
+ ]
2386
+ };
2387
+ }
2388
+ return {
2389
+ content: [
2390
+ {
2391
+ type: "text",
2392
+ text: `Screenshot saved to: ${result.result}`
2393
+ }
2394
+ ]
2395
+ };
2396
+ });
2397
+ // Tool: OCR Screenshot - Extract text with coordinates from screenshot
2398
+ registerToolWithTelemetry("ocr_screenshot", {
2399
+ 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. " +
2400
+ "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. " +
2401
+ "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.",
2402
+ inputSchema: {
2403
+ platform: z.enum(["ios", "android"]).describe("Platform to capture screenshot from"),
2404
+ deviceId: z
2405
+ .string()
2406
+ .optional()
2407
+ .describe("Optional device ID (Android) or UDID (iOS). Uses first available device if not specified.")
2408
+ }
2409
+ }, async ({ platform, deviceId }) => {
2410
+ // Call the HTTP endpoint for OCR (allows hot-reload without session restart)
2411
+ // Prefer child process port, fall back to in-process port
2412
+ const port = getDebugServerPort();
2413
+ if (!port) {
2414
+ return {
2415
+ content: [
2416
+ {
2417
+ type: "text",
2418
+ text: "Debug HTTP server not running"
2419
+ }
2420
+ ],
2421
+ isError: true
2422
+ };
2423
+ }
2424
+ try {
2425
+ const params = new URLSearchParams({ platform, engine: "auto" });
2426
+ if (deviceId)
2427
+ params.set("deviceId", deviceId);
2428
+ const response = await fetch(`http://localhost:${port}/api/ocr?${params}`);
2429
+ const ocrResult = await response.json();
2430
+ if (!ocrResult.success) {
2431
+ return {
2432
+ content: [
2433
+ {
2434
+ type: "text",
2435
+ text: `OCR failed: ${ocrResult.error || "Unknown error"}`
2436
+ }
2437
+ ],
2438
+ isError: true
2439
+ };
2440
+ }
2441
+ // Format results for MCP tool output
2442
+ const elements = ocrResult.words
2443
+ .filter((w) => w.confidence > 50 && w.text.trim().length > 0)
2444
+ .map((w) => ({
2445
+ text: w.text,
2446
+ confidence: Math.round(w.confidence),
2447
+ tapX: w.tapCenter.x,
2448
+ tapY: w.tapCenter.y
2449
+ }));
2450
+ const result = {
2451
+ platform,
2452
+ engine: ocrResult.engine || "unknown",
2453
+ processingTimeMs: ocrResult.processingTimeMs,
2454
+ fullText: ocrResult.fullText?.trim() || "",
2455
+ confidence: Math.round(ocrResult.confidence || 0),
2456
+ elementCount: elements.length,
2457
+ elements,
2458
+ note: "tapX/tapY are ready to use with tap commands (already converted for platform)"
2459
+ };
2460
+ return {
2461
+ content: [
2462
+ {
2463
+ type: "text",
2464
+ text: JSON.stringify(result, null, 2)
2465
+ }
2466
+ ]
2467
+ };
2468
+ }
2469
+ catch (error) {
2470
+ return {
2471
+ content: [
2472
+ {
2473
+ type: "text",
2474
+ text: `OCR request failed: ${error instanceof Error ? error.message : String(error)}`
2475
+ }
2476
+ ],
2477
+ isError: true
2478
+ };
2479
+ }
2480
+ });
2481
+ // Tool: iOS install app
2482
+ registerToolWithTelemetry("ios_install_app", {
2483
+ description: "Install an app bundle (.app) on an iOS simulator",
2484
+ inputSchema: {
2485
+ appPath: z.string().describe("Path to the .app bundle to install"),
2486
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2487
+ }
2488
+ }, async ({ appPath, udid }) => {
2489
+ const result = await iosInstallApp(appPath, udid);
2490
+ return {
2491
+ content: [
2492
+ {
2493
+ type: "text",
2494
+ text: result.success ? result.result : `Error: ${result.error}`
2495
+ }
2496
+ ],
2497
+ isError: !result.success
2498
+ };
2499
+ });
2500
+ // Tool: iOS launch app
2501
+ registerToolWithTelemetry("ios_launch_app", {
2502
+ description: "Launch an app on an iOS simulator by bundle ID",
2503
+ inputSchema: {
2504
+ bundleId: z.string().describe("Bundle ID of the app (e.g., com.example.myapp)"),
2505
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2506
+ }
2507
+ }, async ({ bundleId, udid }) => {
2508
+ const result = await iosLaunchApp(bundleId, udid);
2509
+ return {
2510
+ content: [
2511
+ {
2512
+ type: "text",
2513
+ text: result.success ? result.result : `Error: ${result.error}`
2514
+ }
2515
+ ],
2516
+ isError: !result.success
2517
+ };
2518
+ });
2519
+ // Tool: iOS open URL
2520
+ registerToolWithTelemetry("ios_open_url", {
2521
+ description: "Open a URL in the iOS simulator (opens in default handler or Safari)",
2522
+ inputSchema: {
2523
+ url: z.string().describe("URL to open (e.g., https://example.com or myapp://path)"),
2524
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2525
+ }
2526
+ }, async ({ url, udid }) => {
2527
+ const result = await iosOpenUrl(url, udid);
2528
+ return {
2529
+ content: [
2530
+ {
2531
+ type: "text",
2532
+ text: result.success ? result.result : `Error: ${result.error}`
2533
+ }
2534
+ ],
2535
+ isError: !result.success
2536
+ };
2537
+ });
2538
+ // Tool: iOS terminate app
2539
+ registerToolWithTelemetry("ios_terminate_app", {
2540
+ description: "Terminate a running app on an iOS simulator",
2541
+ inputSchema: {
2542
+ bundleId: z.string().describe("Bundle ID of the app to terminate"),
2543
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2544
+ }
2545
+ }, async ({ bundleId, udid }) => {
2546
+ const result = await iosTerminateApp(bundleId, udid);
2547
+ return {
2548
+ content: [
2549
+ {
2550
+ type: "text",
2551
+ text: result.success ? result.result : `Error: ${result.error}`
2552
+ }
2553
+ ],
2554
+ isError: !result.success
2555
+ };
2556
+ });
2557
+ // Tool: iOS boot simulator
2558
+ registerToolWithTelemetry("ios_boot_simulator", {
2559
+ description: "Boot an iOS simulator by UDID. Use list_ios_simulators to find available simulators.",
2560
+ inputSchema: {
2561
+ udid: z.string().describe("UDID of the simulator to boot (from list_ios_simulators)")
2562
+ }
2563
+ }, async ({ udid }) => {
2564
+ const result = await iosBootSimulator(udid);
2565
+ return {
2566
+ content: [
2567
+ {
2568
+ type: "text",
2569
+ text: result.success ? result.result : `Error: ${result.error}`
2570
+ }
2571
+ ],
2572
+ isError: !result.success
2573
+ };
2574
+ });
2575
+ // ============================================================================
2576
+ // iOS IDB-Based UI Tools (require Facebook IDB)
2577
+ // Install with: brew install idb-companion
2578
+ // ============================================================================
2579
+ // Tool: iOS swipe
2580
+ server.registerTool("ios_swipe", {
2581
+ description: "Swipe gesture on an iOS simulator screen. Requires IDB to be installed (brew install idb-companion).",
2582
+ inputSchema: {
2583
+ startX: z.coerce.number().describe("Starting X coordinate in pixels"),
2584
+ startY: z.coerce.number().describe("Starting Y coordinate in pixels"),
2585
+ endX: z.coerce.number().describe("Ending X coordinate in pixels"),
2586
+ endY: z.coerce.number().describe("Ending Y coordinate in pixels"),
2587
+ duration: z.coerce.number().optional().describe("Optional swipe duration in seconds"),
2588
+ delta: z.coerce.number().optional().describe("Optional delta between touch events (step size)"),
2589
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2590
+ }
2591
+ }, async ({ startX, startY, endX, endY, duration, delta, udid }) => {
2592
+ const result = await iosSwipe(startX, startY, endX, endY, { duration, delta, udid });
2593
+ return {
2594
+ content: [
2595
+ {
2596
+ type: "text",
2597
+ text: result.success ? result.result : `Error: ${result.error}`
2598
+ }
2599
+ ],
2600
+ isError: !result.success
2601
+ };
2602
+ });
2603
+ // Tool: iOS input text
2604
+ server.registerTool("ios_input_text", {
2605
+ description: "Type text into the active input field on an iOS simulator. Requires IDB to be installed (brew install idb-companion).",
2606
+ inputSchema: {
2607
+ text: z.string().describe("Text to type into the active input field"),
2608
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2609
+ }
2610
+ }, async ({ text, udid }) => {
2611
+ const result = await iosInputText(text, udid);
2612
+ return {
2613
+ content: [
2614
+ {
2615
+ type: "text",
2616
+ text: result.success ? result.result : `Error: ${result.error}`
2617
+ }
2618
+ ],
2619
+ isError: !result.success
2620
+ };
2621
+ });
2622
+ // Tool: iOS button
2623
+ server.registerTool("ios_button", {
2624
+ description: "Press a hardware button on an iOS simulator. Requires IDB to be installed (brew install idb-companion).",
2625
+ inputSchema: {
2626
+ button: z
2627
+ .enum(IOS_BUTTON_TYPES)
2628
+ .describe("Hardware button to press: HOME, LOCK, SIDE_BUTTON, SIRI, or APPLE_PAY"),
2629
+ duration: z.coerce.number().optional().describe("Optional button press duration in seconds"),
2630
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2631
+ }
2632
+ }, async ({ button, duration, udid }) => {
2633
+ const result = await iosButton(button, { duration, udid });
2634
+ return {
2635
+ content: [
2636
+ {
2637
+ type: "text",
2638
+ text: result.success ? result.result : `Error: ${result.error}`
2639
+ }
2640
+ ],
2641
+ isError: !result.success
2642
+ };
2643
+ });
2644
+ // Tool: iOS key event
2645
+ server.registerTool("ios_key_event", {
2646
+ description: "Send a key event to an iOS simulator by keycode. Requires IDB to be installed (brew install idb-companion).",
2647
+ inputSchema: {
2648
+ keycode: z.coerce.number().describe("iOS keycode to send"),
2649
+ duration: z.coerce.number().optional().describe("Optional key press duration in seconds"),
2650
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2651
+ }
2652
+ }, async ({ keycode, duration, udid }) => {
2653
+ const result = await iosKeyEvent(keycode, { duration, udid });
2654
+ return {
2655
+ content: [
2656
+ {
2657
+ type: "text",
2658
+ text: result.success ? result.result : `Error: ${result.error}`
2659
+ }
2660
+ ],
2661
+ isError: !result.success
2662
+ };
2663
+ });
2664
+ // Tool: iOS key sequence
2665
+ server.registerTool("ios_key_sequence", {
2666
+ description: "Send a sequence of key events to an iOS simulator. Requires IDB to be installed (brew install idb-companion).",
2667
+ inputSchema: {
2668
+ keycodes: z.array(z.coerce.number()).describe("Array of iOS keycodes to send in sequence"),
2669
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2670
+ }
2671
+ }, async ({ keycodes, udid }) => {
2672
+ const result = await iosKeySequence(keycodes, udid);
2673
+ return {
2674
+ content: [
2675
+ {
2676
+ type: "text",
2677
+ text: result.success ? result.result : `Error: ${result.error}`
2678
+ }
2679
+ ],
2680
+ isError: !result.success
2681
+ };
2682
+ });
2683
+ // Tool: iOS describe all (accessibility tree)
2684
+ server.registerTool("ios_describe_all", {
2685
+ description: "Get accessibility information for the entire iOS simulator screen. Returns a nested tree of UI elements with labels, values, and frames. Requires IDB to be installed (brew install idb-companion).",
2686
+ inputSchema: {
2687
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2688
+ }
2689
+ }, async ({ udid }) => {
2690
+ const result = await iosDescribeAll(udid);
2691
+ return {
2692
+ content: [
2693
+ {
2694
+ type: "text",
2695
+ text: result.success ? result.result : `Error: ${result.error}`
2696
+ }
2697
+ ],
2698
+ isError: !result.success
2699
+ };
2700
+ });
2701
+ // Tool: iOS describe point
2702
+ server.registerTool("ios_describe_point", {
2703
+ description: "Get accessibility information for the UI element at a specific point on the iOS simulator screen. Requires IDB to be installed (brew install idb-companion).",
2704
+ inputSchema: {
2705
+ x: z.coerce.number().describe("X coordinate in pixels"),
2706
+ y: z.coerce.number().describe("Y coordinate in pixels"),
2707
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2708
+ }
2709
+ }, async ({ x, y, udid }) => {
2710
+ const result = await iosDescribePoint(x, y, udid);
2711
+ return {
2712
+ content: [
2713
+ {
2714
+ type: "text",
2715
+ text: result.success ? result.result : `Error: ${result.error}`
2716
+ }
2717
+ ],
2718
+ isError: !result.success
2719
+ };
2720
+ });
2721
+ // Tool: iOS find element (no screenshot needed)
2722
+ server.registerTool("ios_find_element", {
2723
+ description: "Find a UI element on iOS simulator by accessibility label or value. Returns element details including tap coordinates. Requires IDB (brew install idb-companion). Workflow: 1) wait_for_element, 2) find_element, 3) tap with returned coordinates. Prefer this over screenshots for button taps.",
2724
+ inputSchema: {
2725
+ label: z.string().optional().describe("Exact accessibility label match"),
2726
+ labelContains: z.string().optional().describe("Partial label match (case-insensitive)"),
2727
+ value: z.string().optional().describe("Exact accessibility value match"),
2728
+ valueContains: z.string().optional().describe("Partial value match (case-insensitive)"),
2729
+ type: z.string().optional().describe("Element type to match (e.g., 'Button', 'TextField')"),
2730
+ index: z
2731
+ .number()
2732
+ .optional()
2733
+ .describe("If multiple elements match, select the nth one (0-indexed, default: 0)"),
2734
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2735
+ }
2736
+ }, async ({ label, labelContains, value, valueContains, type, index, udid }) => {
2737
+ const result = await iosFindElement({ label, labelContains, value, valueContains, type, index }, udid);
2738
+ if (!result.success) {
2739
+ return {
2740
+ content: [{ type: "text", text: `Error: ${result.error}` }],
2741
+ isError: true
2742
+ };
2743
+ }
2744
+ if (!result.found) {
2745
+ return {
2746
+ content: [
2747
+ {
2748
+ type: "text",
2749
+ text: result.error || "Element not found"
2750
+ }
2751
+ ]
2752
+ };
2753
+ }
2754
+ const el = result.element;
2755
+ const info = [
2756
+ `Found element (${result.matchCount} match${result.matchCount > 1 ? "es" : ""})`,
2757
+ ` Label: "${el.label}"`,
2758
+ ` Value: "${el.value}"`,
2759
+ ` Type: ${el.type}`,
2760
+ ` Frame: {x: ${el.frame.x}, y: ${el.frame.y}, w: ${el.frame.width}, h: ${el.frame.height}}`,
2761
+ ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
2762
+ ` Enabled: ${el.enabled}`
2763
+ ].join("\n");
2764
+ return {
2765
+ content: [{ type: "text", text: info }]
2766
+ };
2767
+ });
2768
+ // Tool: iOS wait for element
2769
+ server.registerTool("ios_wait_for_element", {
2770
+ description: "Wait for a UI element to appear on iOS simulator. Polls until found or timeout. Requires IDB (brew install idb-companion). Use this FIRST after navigation to ensure screen is ready, then use find_element + tap.",
2771
+ inputSchema: {
2772
+ label: z.string().optional().describe("Exact accessibility label match"),
2773
+ labelContains: z.string().optional().describe("Partial label match (case-insensitive)"),
2774
+ value: z.string().optional().describe("Exact accessibility value match"),
2775
+ valueContains: z.string().optional().describe("Partial value match (case-insensitive)"),
2776
+ type: z.string().optional().describe("Element type to match (e.g., 'Button', 'TextField')"),
2777
+ index: z
2778
+ .number()
2779
+ .optional()
2780
+ .describe("If multiple elements match, select the nth one (0-indexed, default: 0)"),
2781
+ timeoutMs: z
2782
+ .number()
2783
+ .optional()
2784
+ .default(10000)
2785
+ .describe("Maximum time to wait in milliseconds (default: 10000)"),
2786
+ pollIntervalMs: z
2787
+ .number()
2788
+ .optional()
2789
+ .default(500)
2790
+ .describe("Time between polls in milliseconds (default: 500)"),
2791
+ udid: z.string().optional().describe("Optional simulator UDID. Uses booted simulator if not specified.")
2792
+ }
2793
+ }, async ({ label, labelContains, value, valueContains, type, index, timeoutMs, pollIntervalMs, udid }) => {
2794
+ const result = await iosWaitForElement({
2795
+ label,
2796
+ labelContains,
2797
+ value,
2798
+ valueContains,
2799
+ type,
2800
+ index,
2801
+ timeoutMs,
2802
+ pollIntervalMs
2803
+ }, udid);
2804
+ if (!result.success) {
2805
+ return {
2806
+ content: [{ type: "text", text: `Error: ${result.error}` }],
2807
+ isError: true
2808
+ };
2809
+ }
2810
+ if (!result.found) {
2811
+ return {
2812
+ content: [
2813
+ {
2814
+ type: "text",
2815
+ text: result.timedOut
2816
+ ? `Timed out after ${result.elapsedMs}ms - element not found`
2817
+ : result.error || "Element not found"
2818
+ }
2819
+ ]
2820
+ };
2821
+ }
2822
+ const el = result.element;
2823
+ const info = [
2824
+ `Element found after ${result.elapsedMs}ms`,
2825
+ ` Label: "${el.label}"`,
2826
+ ` Value: "${el.value}"`,
2827
+ ` Type: ${el.type}`,
2828
+ ` Center (tap coords): (${el.center.x}, ${el.center.y})`,
2829
+ ` Enabled: ${el.enabled}`
2830
+ ].join("\n");
2831
+ return {
2832
+ content: [{ type: "text", text: info }]
2833
+ };
2834
+ });
2835
+ // Tool: Get debug server info
2836
+ registerToolWithTelemetry("get_debug_server", {
2837
+ description: "Get the debug HTTP server URL. Use this to find where you can access logs, network requests, and other debug data via HTTP.",
2838
+ inputSchema: {}
2839
+ }, async () => {
2840
+ const port = getDebugServerPort();
2841
+ if (!port) {
2842
+ return {
2843
+ content: [
2844
+ {
2845
+ type: "text",
2846
+ text: "Debug HTTP server is not running."
2847
+ }
2848
+ ],
2849
+ isError: true
2850
+ };
2851
+ }
2852
+ const info = {
2853
+ url: `http://localhost:${port}`,
2854
+ endpoints: {
2855
+ status: `http://localhost:${port}/api/status`,
2856
+ logs: `http://localhost:${port}/api/logs`,
2857
+ network: `http://localhost:${port}/api/network`,
2858
+ bundleErrors: `http://localhost:${port}/api/bundle-errors`,
2859
+ apps: `http://localhost:${port}/api/apps`
2860
+ }
2861
+ };
2862
+ return {
2863
+ content: [
2864
+ {
2865
+ type: "text",
2866
+ text: `Debug HTTP server running at:\n\n${JSON.stringify(info, null, 2)}`
2867
+ }
2868
+ ]
2869
+ };
2870
+ });
2871
+ // Tool: Restart HTTP server (hot-reload)
2872
+ registerToolWithTelemetry("restart_http_server", {
2873
+ description: "Note: HTTP server now runs in-process to share state. To apply code changes, restart the MCP session.",
2874
+ inputSchema: {}
2875
+ }, async () => {
2876
+ const port = getDebugServerPort();
2877
+ return {
2878
+ content: [
2879
+ {
2880
+ type: "text",
2881
+ text: `HTTP server is running in-process on port ${port}. To apply code changes, rebuild with 'npm run build' and restart the MCP session. The in-process mode is required for the dashboard to show logs, network requests, and connected apps.`
2882
+ }
2883
+ ]
2884
+ };
2885
+ });
2886
+ /**
2887
+ * Auto-connect to Metro bundler on startup
2888
+ * Scans common ports and connects to any running Metro servers
2889
+ */
2890
+ async function autoConnectToMetro() {
2891
+ console.error("[rn-ai-debugger] Auto-scanning for Metro servers...");
2892
+ try {
2893
+ const openPorts = await scanMetroPorts();
2894
+ if (openPorts.length === 0) {
2895
+ console.error("[rn-ai-debugger] No Metro servers found on startup. Use scan_metro to connect later.");
2896
+ return;
2897
+ }
2898
+ for (const port of openPorts) {
2899
+ try {
2900
+ const devices = await fetchDevices(port);
2901
+ const mainDevice = selectMainDevice(devices);
2902
+ if (mainDevice) {
2903
+ await connectToDevice(mainDevice, port);
2904
+ console.error(`[rn-ai-debugger] Auto-connected to ${mainDevice.title} on port ${port}`);
2905
+ // Also connect to Metro build events
2906
+ try {
2907
+ await connectMetroBuildEvents(port);
2908
+ }
2909
+ catch {
2910
+ // Build events connection is optional
2911
+ }
2912
+ }
2913
+ }
2914
+ catch (error) {
2915
+ console.error(`[rn-ai-debugger] Failed to auto-connect on port ${port}: ${error}`);
2916
+ }
2917
+ }
2918
+ }
2919
+ catch (error) {
2920
+ console.error(`[rn-ai-debugger] Auto-connect error: ${error}`);
2921
+ }
2922
+ }
2923
+ // Main function
2924
+ async function main() {
2925
+ // Initialize telemetry (checks opt-out env var, loads/creates installation ID)
2926
+ initTelemetry();
2927
+ // Start debug HTTP server in-process (shares state with MCP server)
2928
+ // Note: Child process mode doesn't work because state (logs, network, apps) isn't shared
2929
+ await startDebugHttpServer();
2930
+ console.error("[rn-ai-debugger] HTTP server started in-process");
2931
+ const useHttp = process.argv.includes("--http");
2932
+ const httpPort = parseInt(process.env.MCP_HTTP_PORT || "8600", 10);
2933
+ if (useHttp) {
2934
+ // Register dev meta-tool — proxies calls to any tool using the latest handlers
2935
+ server.registerTool("dev", {
2936
+ description: 'Development meta-tool for hot-reload testing. Use action="list" to get all available tools with descriptions. ' +
2937
+ 'Use action="call" with tool and args to invoke any tool using the latest code after hot-reload. ' +
2938
+ "This tool always reflects the latest server code without needing a session restart.",
2939
+ inputSchema: {
2940
+ action: z.enum(["list", "call"]).describe('"list" to see all tools, "call" to invoke a tool'),
2941
+ tool: z.string().optional().describe("Tool name to call (required when action is call)"),
2942
+ args: z.record(z.any()).optional().describe("Arguments to pass to the tool (optional, default {})"),
2943
+ },
2944
+ }, async ({ action, tool, args }) => {
2945
+ if (action === "list") {
2946
+ const tools = Array.from(toolRegistry.entries()).map(([name, { config }]) => ({
2947
+ name,
2948
+ description: config.description || "",
2949
+ }));
2950
+ return {
2951
+ content: [{ type: "text", text: JSON.stringify(tools, null, 2) }],
2952
+ };
2953
+ }
2954
+ if (action === "call") {
2955
+ if (!tool) {
2956
+ return {
2957
+ content: [{ type: "text", text: 'Error: "tool" parameter is required when action is "call"' }],
2958
+ isError: true,
2959
+ };
2960
+ }
2961
+ const entry = toolRegistry.get(tool);
2962
+ if (!entry) {
2963
+ return {
2964
+ content: [{ type: "text", text: `Error: Tool "${tool}" not found. Use action="list" to see available tools.` }],
2965
+ isError: true,
2966
+ };
2967
+ }
2968
+ return await entry.handler(args || {});
2969
+ }
2970
+ return {
2971
+ content: [{ type: "text", text: 'Error: action must be "list" or "call"' }],
2972
+ isError: true,
2973
+ };
2974
+ });
2975
+ // HTTP transport mode — stateless for dev hot-reload
2976
+ // Stateless = no session IDs, so server restarts don't break Claude Code's connection
2977
+ const transport = new StreamableHTTPServerTransport({
2978
+ sessionIdGenerator: undefined,
2979
+ });
2980
+ await server.connect(transport);
2981
+ const httpServer = createHttpServer(async (req, res) => {
2982
+ const url = new URL(req.url || "", `http://localhost:${httpPort}`);
2983
+ if (url.pathname === "/mcp") {
2984
+ await transport.handleRequest(req, res);
2985
+ return;
2986
+ }
2987
+ res.writeHead(404);
2988
+ res.end("Not found");
2989
+ });
2990
+ httpServer.listen(httpPort, () => {
2991
+ console.error(`[rn-ai-debugger] MCP HTTP server listening on http://localhost:${httpPort}/mcp`);
2992
+ });
2993
+ }
2994
+ else {
2995
+ // Stdio transport mode — default for production
2996
+ const transport = new StdioServerTransport();
2997
+ await server.connect(transport);
2998
+ console.error("[rn-ai-debugger] Server started on stdio");
2999
+ }
3000
+ // Auto-connect to Metro in background (non-blocking)
3001
+ // Use setImmediate to ensure MCP server is fully ready first
3002
+ setImmediate(() => {
3003
+ autoConnectToMetro().catch((err) => {
3004
+ console.error("[rn-ai-debugger] Auto-connect failed:", err);
3005
+ });
3006
+ });
3007
+ }
3008
+ main().catch((error) => {
3009
+ console.error("[rn-ai-debugger] Fatal error:", error);
3010
+ process.exit(1);
3011
+ });
3012
+ //# sourceMappingURL=index.js.map