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