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