@ohah/react-native-mcp-server 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,3829 @@
1
+ #!/usr/bin/env node
2
+ import { a as isAndroidEmulator, c as runAdbCommand, i as getAndroidTopInset, l as runCommand, n as checkAdbAvailable, o as listAdbDevices, r as getAndroidScale, s as resolveSerial, t as adbNotInstalledError } from "./adb-utils-DreOsWp-.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { WebSocket, WebSocketServer } from "ws";
6
+ import { execFile } from "node:child_process";
7
+ import { z } from "zod";
8
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import * as path$1 from "node:path";
11
+ import { dirname, join } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import { XMLParser } from "fast-xml-parser";
14
+
15
+ //#region src/tools/metro-cdp.ts
16
+ /** deviceId → metroBaseUrl */
17
+ const deviceMetroUrls = /* @__PURE__ */ new Map();
18
+ let metroBaseUrlFromApp = null;
19
+ function setMetroBaseUrlFromApp(url, deviceId) {
20
+ if (deviceId) if (url) deviceMetroUrls.set(deviceId, url);
21
+ else deviceMetroUrls.delete(deviceId);
22
+ else metroBaseUrlFromApp = url;
23
+ }
24
+
25
+ //#endregion
26
+ //#region src/websocket-server.ts
27
+ /**
28
+ * MCP 서버 ↔ React Native 앱 간 WebSocket 서버 (Phase 1)
29
+ * 포트 12300에서 앱 연결 수락, eval 요청 전송 및 응답 수신.
30
+ * 다중 연결 지원: 디바이스별 deviceId(예: ios-1, android-1) 할당.
31
+ */
32
+ const DEFAULT_PORT = 12300;
33
+ /**
34
+ * 앱 연결 세션: 다중 WebSocket, 디바이스별 요청/응답 매칭
35
+ */
36
+ var AppSession = class {
37
+ devices = /* @__PURE__ */ new Map();
38
+ deviceByWs = /* @__PURE__ */ new Map();
39
+ awaitingInit = /* @__PURE__ */ new Set();
40
+ extensionClients = /* @__PURE__ */ new Set();
41
+ server = null;
42
+ staleCheckTimer = null;
43
+ /** 연결된 디바이스 중 열린 것 하나로 해석 (deviceId/platform 미지정 시) */
44
+ resolveDevice(deviceId, platform) {
45
+ const open = () => [...this.devices.values()].filter((c) => c.ws.readyState === WebSocket.OPEN);
46
+ if (deviceId != null && deviceId !== "") {
47
+ const conn = this.devices.get(deviceId);
48
+ if (!conn || conn.ws.readyState !== WebSocket.OPEN) throw new Error("Device not connected: " + deviceId);
49
+ return conn;
50
+ }
51
+ if (platform != null && platform !== "") {
52
+ const list = open().filter((c) => c.platform === platform);
53
+ if (list.length === 0) throw new Error("No " + platform + " device connected");
54
+ if (list.length > 1) throw new Error("Multiple " + platform + " devices connected");
55
+ const byPlatform = list[0];
56
+ if (!byPlatform) throw new Error("No " + platform + " device connected");
57
+ return byPlatform;
58
+ }
59
+ const list = open();
60
+ if (list.length === 0) throw new Error("No React Native app connected. Start the app with Metro and ensure the runtime is loaded.");
61
+ if (list.length > 1) throw new Error("Multiple devices connected. Specify deviceId or platform.");
62
+ const single = list[0];
63
+ if (!single) throw new Error("No React Native app connected.");
64
+ return single;
65
+ }
66
+ /** 현재 앱이 연결되어 있는지 (인자 없음: 1대 이상 연결 시 true) */
67
+ isConnected(deviceId, platform) {
68
+ try {
69
+ if (deviceId == null && platform == null) return [...this.devices.values()].some((c) => c.ws.readyState === WebSocket.OPEN);
70
+ this.resolveDevice(deviceId, platform);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+ /**
77
+ * 연결된 디바이스 목록 (get_debugger_status 등에서 사용).
78
+ * 열린 연결만 반환, 각 항목에 connected: true 포함.
79
+ */
80
+ getConnectedDevices() {
81
+ return [...this.devices.values()].filter((c) => c.ws.readyState === WebSocket.OPEN).map((c) => {
82
+ const ratio = c.pixelRatio ?? 1;
83
+ return {
84
+ deviceId: c.deviceId,
85
+ platform: c.platform,
86
+ deviceName: c.deviceName,
87
+ connected: true,
88
+ topInsetDp: c.platform === "android" && c.topInsetPx > 0 ? c.topInsetPx / ratio : 0
89
+ };
90
+ });
91
+ }
92
+ /** 연결된 디바이스의 pixelRatio 반환 (없으면 null) */
93
+ getPixelRatio(deviceId, platform) {
94
+ try {
95
+ return this.resolveDevice(deviceId, platform).pixelRatio;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Android top inset을 dp 단위로 반환.
102
+ * topInsetPx / pixelRatio. iOS는 항상 0.
103
+ */
104
+ getTopInsetDp(deviceId, platform) {
105
+ try {
106
+ const conn = this.resolveDevice(deviceId, platform);
107
+ if (conn.platform !== "android" || conn.topInsetPx === 0) return 0;
108
+ const ratio = conn.pixelRatio ?? 1;
109
+ return conn.topInsetPx / ratio;
110
+ } catch {
111
+ return 0;
112
+ }
113
+ }
114
+ /**
115
+ * 수동으로 Android top inset(dp) 설정.
116
+ * ADB 자동 감지 결과를 덮어쓴다.
117
+ */
118
+ setTopInsetDp(dp, deviceId, platform) {
119
+ const conn = this.resolveDevice(deviceId, platform);
120
+ const ratio = conn.pixelRatio ?? 1;
121
+ conn.topInsetPx = Math.round(dp * ratio);
122
+ }
123
+ /** 디버깅: WebSocket 서버/클라이언트 상태 */
124
+ getConnectionStatus() {
125
+ const open = [...this.devices.values()].filter((c) => c.ws.readyState === WebSocket.OPEN);
126
+ return {
127
+ connected: open.length > 0,
128
+ hasServer: this.server != null,
129
+ deviceCount: open.length
130
+ };
131
+ }
132
+ /** 테스트용: 디바이스를 직접 주입 (테스트에서만 사용) */
133
+ _testInjectDevice(conn) {
134
+ this.devices.set(conn.deviceId, conn);
135
+ this.deviceByWs.set(conn.ws, conn.deviceId);
136
+ }
137
+ nextDeviceId(platform) {
138
+ const indices = [...this.devices.values()].filter((c) => c.platform === platform).map((c) => {
139
+ const m = c.deviceId.match(/^(.+)-(\d+)$/);
140
+ return m && m[2] !== void 0 ? parseInt(m[2], 10) : 0;
141
+ });
142
+ return `${platform}-${(indices.length > 0 ? Math.max(...indices) : 0) + 1}`;
143
+ }
144
+ /** Broadcast a message to all connected extension clients. */
145
+ broadcastToExtensions(msg) {
146
+ const data = JSON.stringify(msg);
147
+ for (const ext of this.extensionClients) if (ext.readyState === WebSocket.OPEN) ext.send(data);
148
+ }
149
+ removeConnection(ws) {
150
+ if (this.extensionClients.delete(ws)) return;
151
+ const deviceId = this.deviceByWs.get(ws);
152
+ if (deviceId) {
153
+ setMetroBaseUrlFromApp(null, deviceId);
154
+ const conn = this.devices.get(deviceId);
155
+ if (conn) {
156
+ for (const { reject } of conn.pending.values()) reject(/* @__PURE__ */ new Error("Device disconnected"));
157
+ conn.pending.clear();
158
+ }
159
+ this.devices.delete(deviceId);
160
+ this.deviceByWs.delete(ws);
161
+ this.broadcastToExtensions({
162
+ type: "devices-changed",
163
+ devices: this.getConnectedDevices()
164
+ });
165
+ }
166
+ this.awaitingInit.delete(ws);
167
+ }
168
+ /**
169
+ * WebSocket 서버 시작 (지정 포트, 기본 12300)
170
+ */
171
+ start(port = DEFAULT_PORT) {
172
+ if (this.server) return;
173
+ this.server = new WebSocketServer({ port });
174
+ this.server.on("connection", (ws) => {
175
+ console.error("[react-native-mcp-server] WebSocket client connected");
176
+ this.awaitingInit.add(ws);
177
+ ws.on("message", (data) => {
178
+ try {
179
+ const msg = JSON.parse(data.toString());
180
+ const existingDeviceId = this.deviceByWs.get(ws);
181
+ if (existingDeviceId) {
182
+ const existingConn = this.devices.get(existingDeviceId);
183
+ if (existingConn) existingConn.lastMessageTime = Date.now();
184
+ }
185
+ if (msg?.type === "ping") {
186
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "pong" }));
187
+ return;
188
+ }
189
+ if (this.awaitingInit.has(ws) && msg?.type === "extension-init") {
190
+ this.awaitingInit.delete(ws);
191
+ this.extensionClients.add(ws);
192
+ ws.send(JSON.stringify({
193
+ type: "extension-init-ack",
194
+ devices: this.getConnectedDevices()
195
+ }));
196
+ return;
197
+ }
198
+ if (this.extensionClients.has(ws) && msg?.method === "eval") {
199
+ const reqId = typeof msg.id === "string" ? msg.id : crypto.randomUUID();
200
+ const params = msg.params ?? {};
201
+ const deviceId = typeof params.deviceId === "string" ? params.deviceId : void 0;
202
+ const platform = typeof params.platform === "string" ? params.platform : void 0;
203
+ const code = typeof params.code === "string" ? params.code : "";
204
+ this.sendRequest({
205
+ method: "eval",
206
+ params: { code }
207
+ }, 1e4, deviceId, platform).then((res) => {
208
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
209
+ id: reqId,
210
+ result: res.result,
211
+ error: res.error
212
+ }));
213
+ }).catch((err) => {
214
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
215
+ id: reqId,
216
+ error: err instanceof Error ? err.message : String(err)
217
+ }));
218
+ });
219
+ return;
220
+ }
221
+ if (this.extensionClients.has(ws) && msg?.method === "getDevices") {
222
+ const reqId = typeof msg.id === "string" ? msg.id : crypto.randomUUID();
223
+ ws.send(JSON.stringify({
224
+ id: reqId,
225
+ result: this.getConnectedDevices()
226
+ }));
227
+ return;
228
+ }
229
+ if (this.awaitingInit.has(ws) && msg?.type === "init") {
230
+ this.awaitingInit.delete(ws);
231
+ const platform = typeof msg.platform === "string" ? msg.platform : "unknown";
232
+ const deviceId = this.nextDeviceId(platform);
233
+ const deviceName = typeof msg.deviceName === "string" ? msg.deviceName : null;
234
+ const metroBaseUrl = typeof msg.metroBaseUrl === "string" ? msg.metroBaseUrl : null;
235
+ const pixelRatio = typeof msg.pixelRatio === "number" ? msg.pixelRatio : null;
236
+ const conn = {
237
+ deviceId,
238
+ platform,
239
+ deviceName,
240
+ ws,
241
+ pending: /* @__PURE__ */ new Map(),
242
+ metroBaseUrl,
243
+ pixelRatio,
244
+ topInsetPx: 0,
245
+ lastMessageTime: Date.now()
246
+ };
247
+ this.devices.set(deviceId, conn);
248
+ this.deviceByWs.set(ws, deviceId);
249
+ if (metroBaseUrl) setMetroBaseUrlFromApp(metroBaseUrl, deviceId);
250
+ this.broadcastToExtensions({
251
+ type: "devices-changed",
252
+ devices: this.getConnectedDevices()
253
+ });
254
+ if (platform === "android") {
255
+ const userTopInset = typeof msg.topInsetDp === "number" ? msg.topInsetDp : null;
256
+ if (userTopInset != null) {
257
+ const ratio = pixelRatio ?? 1;
258
+ conn.topInsetPx = Math.round(userTopInset * ratio);
259
+ } else getAndroidTopInset().then((px) => {
260
+ conn.topInsetPx = px;
261
+ }).catch(() => {});
262
+ }
263
+ return;
264
+ }
265
+ const respDeviceId = this.deviceByWs.get(ws);
266
+ if (respDeviceId != null) {
267
+ const conn = this.devices.get(respDeviceId);
268
+ const res = msg;
269
+ if (res?.id && conn?.pending.has(res.id)) {
270
+ const { resolve } = conn.pending.get(res.id);
271
+ conn.pending.delete(res.id);
272
+ resolve(res);
273
+ }
274
+ }
275
+ } catch {}
276
+ });
277
+ ws.on("close", () => {
278
+ console.error("[react-native-mcp-server] WebSocket client disconnected");
279
+ this.removeConnection(ws);
280
+ });
281
+ ws.on("error", () => {
282
+ this.removeConnection(ws);
283
+ });
284
+ });
285
+ this.server.on("listening", () => {
286
+ console.error(`[react-native-mcp-server] WebSocket server listening on ws://localhost:${port}`);
287
+ });
288
+ this.staleCheckTimer = setInterval(() => {
289
+ const now = Date.now();
290
+ for (const conn of this.devices.values()) if (now - conn.lastMessageTime > 6e4) {
291
+ console.error(`[react-native-mcp-server] Closing stale connection: ${conn.deviceId} (no message for 60s)`);
292
+ conn.ws.close();
293
+ }
294
+ }, 15e3);
295
+ this.server.on("error", (err) => {
296
+ if (err.message?.includes("EADDRINUSE")) {
297
+ console.error("[react-native-mcp-server] Port %s already in use. Only one MCP server should run. Kill the other process: kill $(lsof -t -i :%s)", port, port);
298
+ process.exit(1);
299
+ }
300
+ console.error("[react-native-mcp-server] WebSocket server error:", err.message);
301
+ });
302
+ }
303
+ /**
304
+ * 앱에 요청 전송 후 응답 대기 (타임아웃 ms).
305
+ * deviceId/platform 지정 시 해당 디바이스로, 미지정 시 연결 1대일 때만 가능.
306
+ */
307
+ async sendRequest(request, timeoutMs = 1e4, deviceId, platform) {
308
+ const conn = this.resolveDevice(deviceId, platform);
309
+ const id = crypto.randomUUID();
310
+ const msg = {
311
+ ...request,
312
+ id
313
+ };
314
+ if (conn.ws.readyState !== WebSocket.OPEN) throw new Error("No React Native app connected. Start the app with Metro and ensure the runtime is loaded.");
315
+ return new Promise((resolve, reject) => {
316
+ const t = setTimeout(() => {
317
+ if (conn.pending.delete(id)) reject(/* @__PURE__ */ new Error("Request timeout: no response from app"));
318
+ }, timeoutMs);
319
+ conn.pending.set(id, {
320
+ resolve: (res) => {
321
+ clearTimeout(t);
322
+ resolve(res);
323
+ },
324
+ reject: (err) => {
325
+ clearTimeout(t);
326
+ reject(err);
327
+ }
328
+ });
329
+ conn.ws.send(JSON.stringify(msg));
330
+ });
331
+ }
332
+ /** 서버 종료 */
333
+ stop() {
334
+ if (this.staleCheckTimer) {
335
+ clearInterval(this.staleCheckTimer);
336
+ this.staleCheckTimer = null;
337
+ }
338
+ if (this.server) {
339
+ this.server.close();
340
+ this.server = null;
341
+ }
342
+ for (const ext of this.extensionClients) ext.close();
343
+ this.extensionClients.clear();
344
+ for (const [ws] of this.deviceByWs) this.removeConnection(ws);
345
+ }
346
+ };
347
+ /** 싱글톤 앱 세션 (index에서 생성 후 tools에 전달) */
348
+ const appSession = new AppSession();
349
+
350
+ //#endregion
351
+ //#region src/tools/device-param.ts
352
+ /**
353
+ * 다중 디바이스 라우팅용 공통 파라미터 (deviceId, platform)
354
+ */
355
+ const deviceParam = z.string().optional().describe("Target device ID. Auto if single device. Use get_debugger_status to list.");
356
+ const platformParam = z.enum(["ios", "android"]).optional().describe("Target platform. Auto when single device of that platform.");
357
+
358
+ //#endregion
359
+ //#region src/tools/query-selector.ts
360
+ /**
361
+ * MCP 도구: query_selector, query_selector_all
362
+ * Fiber 트리에서 셀렉터로 요소 검색. CSS querySelector와 유사하지만 React Native Fiber 트리 전용.
363
+ *
364
+ * 셀렉터 문법:
365
+ * Type#testID[attr="val"]:text("..."):nth-of-type(N):has-press:has-scroll
366
+ * A > B (직접 자식), A B (후손), A, B (OR)
367
+ */
368
+ const schema$23 = z.object({
369
+ selector: z.string().describe("Selector for RN Fiber tree. Read resource docs://guides/query-selector-syntax for full syntax (type, #testID, :text, hierarchy, etc.)."),
370
+ deviceId: deviceParam,
371
+ platform: platformParam
372
+ });
373
+ function buildQuerySelectorEvalCode(selector) {
374
+ return `(function(){ var M = typeof __REACT_NATIVE_MCP__ !== 'undefined' ? __REACT_NATIVE_MCP__ : null; return M && M.querySelectorWithMeasure ? M.querySelectorWithMeasure(${JSON.stringify(selector)}) : null; })();`;
375
+ }
376
+ function buildQuerySelectorAllEvalCode(selector) {
377
+ return `(function(){ var M = typeof __REACT_NATIVE_MCP__ !== 'undefined' ? __REACT_NATIVE_MCP__ : null; return M && M.querySelectorAllWithMeasure ? M.querySelectorAllWithMeasure(${JSON.stringify(selector)}) : []; })();`;
378
+ }
379
+ /** WebView 감지 시 등록된 webViewId 목록을 조회해 힌트로 반환 */
380
+ async function getWebViewHint(appSession, deviceId, platform) {
381
+ try {
382
+ const res = await appSession.sendRequest({
383
+ method: "eval",
384
+ params: { code: `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.getRegisteredWebViewIds ? __REACT_NATIVE_MCP__.getRegisteredWebViewIds() : []; })();` }
385
+ }, 5e3, deviceId, platform);
386
+ const ids = Array.isArray(res.result) ? res.result : [];
387
+ if (ids.length === 0) return "\n\n⚠️ This is a WebView but no WebView IDs are registered. Coordinate-based tap may be needed, or ensure the Babel plugin is configured.";
388
+ return `\n\n💡 WebView detected. Use webview_evaluate_script to interact with its DOM content (click, read text, etc.) — do NOT use coordinate-based tap.\nRegistered WebView IDs: [${ids.map((id) => `"${id}"`).join(", ")}]\nExample: webview_evaluate_script(webViewId: ${ids.length === 1 ? `"${ids[0]}"` : `<pick one>`}, script: "document.querySelector('button').click()")`;
389
+ } catch {
390
+ return "\n\n💡 This is a WebView. Use webview_evaluate_script instead of tap for DOM interactions.";
391
+ }
392
+ }
393
+ function isWebViewType(typeName) {
394
+ return typeName === "RNCWebView" || typeName.includes("WebView");
395
+ }
396
+ function registerQuerySelector(server, appSession) {
397
+ const register = (name, description, handler) => {
398
+ server.registerTool(name, {
399
+ description,
400
+ inputSchema: schema$23
401
+ }, handler);
402
+ };
403
+ register("query_selector", "Find first element matching selector in Fiber tree. Returns uid, type, measure (pageX, pageY, width, height). Workflow: query_selector → tap(center); uids unknown until called.", async (args) => {
404
+ const { selector, deviceId, platform } = schema$23.parse(args);
405
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
406
+ type: "text",
407
+ text: "No React Native app connected."
408
+ }] };
409
+ const code = buildQuerySelectorEvalCode(selector);
410
+ try {
411
+ const res = await appSession.sendRequest({
412
+ method: "eval",
413
+ params: { code }
414
+ }, 1e4, deviceId, platform);
415
+ if (res.error != null) return { content: [{
416
+ type: "text",
417
+ text: `Error: ${res.error}`
418
+ }] };
419
+ if (res.result == null) return { content: [{
420
+ type: "text",
421
+ text: "No element matches selector: " + selector
422
+ }] };
423
+ const result = res.result;
424
+ let text = JSON.stringify(result, null, 2);
425
+ if (isWebViewType(String(result.type ?? ""))) text += await getWebViewHint(appSession, deviceId, platform);
426
+ return { content: [{
427
+ type: "text",
428
+ text
429
+ }] };
430
+ } catch (err) {
431
+ return {
432
+ isError: true,
433
+ content: [{
434
+ type: "text",
435
+ text: `query_selector failed: ${err instanceof Error ? err.message : String(err)}`
436
+ }]
437
+ };
438
+ }
439
+ });
440
+ register("query_selector_all", "Find all elements matching selector. Returns array with measure. Prefer query_selector for one element; use only when enumerating multiple.", async (args) => {
441
+ const { selector, deviceId, platform } = schema$23.parse(args);
442
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
443
+ type: "text",
444
+ text: "No React Native app connected."
445
+ }] };
446
+ const code = buildQuerySelectorAllEvalCode(selector);
447
+ try {
448
+ const res = await appSession.sendRequest({
449
+ method: "eval",
450
+ params: { code }
451
+ }, 1e4, deviceId, platform);
452
+ if (res.error != null) return { content: [{
453
+ type: "text",
454
+ text: `Error: ${res.error}`
455
+ }] };
456
+ const list = Array.isArray(res.result) ? res.result : [];
457
+ if (list.length === 0) return { content: [{
458
+ type: "text",
459
+ text: "No elements match selector: " + selector
460
+ }] };
461
+ let text = JSON.stringify(list, null, 2);
462
+ if (list.some((el) => isWebViewType(String(el.type ?? "")))) text += await getWebViewHint(appSession, deviceId, platform);
463
+ return { content: [{
464
+ type: "text",
465
+ text
466
+ }] };
467
+ } catch (err) {
468
+ return {
469
+ isError: true,
470
+ content: [{
471
+ type: "text",
472
+ text: `query_selector_all failed: ${err instanceof Error ? err.message : String(err)}`
473
+ }]
474
+ };
475
+ }
476
+ });
477
+ }
478
+
479
+ //#endregion
480
+ //#region src/tools/assert.ts
481
+ /**
482
+ * MCP 도구: assert_text, assert_visible, assert_not_visible, assert_element_count
483
+ * 테스트 러너용 assertion. 내부적으로 querySelector 사용.
484
+ * timeoutMs/intervalMs 파라미터로 polling 지원 (CI 안정성 향상).
485
+ */
486
+ const timeoutParam = z.number().optional().default(0).describe("Max wait ms. 0 = single check; >0 = poll until pass or timeout.");
487
+ const intervalParam = z.number().optional().default(300).describe("Poll interval ms. Used only when timeoutMs > 0.");
488
+ const assertTextSchema = z.object({
489
+ text: z.string().describe("Text substring to assert exists on screen"),
490
+ selector: z.string().optional().describe("Optional selector to narrow scope."),
491
+ timeoutMs: timeoutParam,
492
+ intervalMs: intervalParam,
493
+ deviceId: deviceParam,
494
+ platform: platformParam
495
+ });
496
+ const assertVisibleSchema = z.object({
497
+ selector: z.string().describe("Selector to check visibility."),
498
+ timeoutMs: timeoutParam,
499
+ intervalMs: intervalParam,
500
+ deviceId: deviceParam,
501
+ platform: platformParam
502
+ });
503
+ const assertNotVisibleSchema = z.object({
504
+ selector: z.string().describe("Selector of element that must not be visible."),
505
+ timeoutMs: timeoutParam,
506
+ intervalMs: intervalParam,
507
+ deviceId: deviceParam,
508
+ platform: platformParam
509
+ });
510
+ const assertElementCountSchema = z.object({
511
+ selector: z.string().describe("Selector to count matching elements."),
512
+ expectedCount: z.number().optional().describe("Exact count. Mutually exclusive with min/maxCount."),
513
+ minCount: z.number().optional().describe("Min count (inclusive)."),
514
+ maxCount: z.number().optional().describe("Max count (inclusive)."),
515
+ timeoutMs: timeoutParam,
516
+ intervalMs: intervalParam,
517
+ deviceId: deviceParam,
518
+ platform: platformParam
519
+ });
520
+ function registerTool(server, name, description, inputSchema, handler) {
521
+ server.registerTool(name, {
522
+ description,
523
+ inputSchema
524
+ }, handler);
525
+ }
526
+ function sleep$1(ms) {
527
+ return new Promise((resolve) => setTimeout(resolve, ms));
528
+ }
529
+ /** 서버 측 polling 루프. checkFn이 pass:true 반환하거나 timeout 초과까지 반복. */
530
+ async function pollUntil(checkFn, timeoutMs, intervalMs) {
531
+ const result = await checkFn();
532
+ if (result.pass || timeoutMs <= 0) return result;
533
+ const start = Date.now();
534
+ let lastResult = result;
535
+ while (Date.now() - start < timeoutMs) {
536
+ await sleep$1(intervalMs);
537
+ lastResult = await checkFn();
538
+ if (lastResult.pass) return lastResult;
539
+ }
540
+ return lastResult;
541
+ }
542
+ function textContent(data) {
543
+ return { content: [{
544
+ type: "text",
545
+ text: data.text
546
+ }] };
547
+ }
548
+ function jsonContent(obj) {
549
+ return { content: [{
550
+ type: "text",
551
+ text: JSON.stringify(obj)
552
+ }] };
553
+ }
554
+ function registerAssert(server, appSession) {
555
+ registerTool(server, "assert_text", "Assert text exists on screen. Returns { pass, message }. Optional selector; supports timeoutMs/intervalMs polling.", assertTextSchema, async (args) => {
556
+ const { text, selector, timeoutMs, intervalMs, deviceId, platform } = assertTextSchema.parse(args);
557
+ if (!appSession.isConnected(deviceId, platform)) return textContent({
558
+ type: "text",
559
+ text: "No React Native app connected."
560
+ });
561
+ const code = buildQuerySelectorEvalCode(selector ? `${selector}:text(${JSON.stringify(text)})` : `:text(${JSON.stringify(text)})`);
562
+ const requestTimeout = Math.max(1e4, timeoutMs + 5e3);
563
+ try {
564
+ const result = await pollUntil(async () => {
565
+ const res = await appSession.sendRequest({
566
+ method: "eval",
567
+ params: { code }
568
+ }, requestTimeout, deviceId, platform);
569
+ if (res.error != null) return {
570
+ pass: false,
571
+ error: res.error
572
+ };
573
+ return { pass: res.result != null };
574
+ }, timeoutMs, intervalMs);
575
+ if (result.error) return {
576
+ isError: true,
577
+ content: [{
578
+ type: "text",
579
+ text: `Error: ${result.error}`
580
+ }]
581
+ };
582
+ const pass = result.pass;
583
+ return jsonContent({
584
+ pass,
585
+ message: pass ? `PASS: text "${text}" found` : `FAIL: text "${text}" not found`
586
+ });
587
+ } catch (err) {
588
+ return {
589
+ isError: true,
590
+ content: [{
591
+ type: "text",
592
+ text: `assert_text failed: ${err instanceof Error ? err.message : String(err)}`
593
+ }]
594
+ };
595
+ }
596
+ });
597
+ registerTool(server, "assert_visible", "Assert element matching selector is visible. Returns { pass, message }. Supports polling.", assertVisibleSchema, async (args) => {
598
+ const { selector, timeoutMs, intervalMs, deviceId, platform } = assertVisibleSchema.parse(args);
599
+ if (!appSession.isConnected(deviceId, platform)) return textContent({
600
+ type: "text",
601
+ text: "No React Native app connected."
602
+ });
603
+ const code = buildQuerySelectorEvalCode(selector);
604
+ const requestTimeout = Math.max(1e4, timeoutMs + 5e3);
605
+ try {
606
+ const result = await pollUntil(async () => {
607
+ const res = await appSession.sendRequest({
608
+ method: "eval",
609
+ params: { code }
610
+ }, requestTimeout, deviceId, platform);
611
+ if (res.error != null) return {
612
+ pass: false,
613
+ error: res.error
614
+ };
615
+ return { pass: res.result != null };
616
+ }, timeoutMs, intervalMs);
617
+ if (result.error) return textContent({
618
+ type: "text",
619
+ text: `Error: ${result.error}`
620
+ });
621
+ const pass = result.pass;
622
+ return jsonContent({
623
+ pass,
624
+ message: pass ? `PASS: element matching "${selector}" found` : `FAIL: element matching "${selector}" not found`
625
+ });
626
+ } catch (err) {
627
+ return {
628
+ isError: true,
629
+ content: [{
630
+ type: "text",
631
+ text: `assert_visible failed: ${err instanceof Error ? err.message : String(err)}`
632
+ }]
633
+ };
634
+ }
635
+ });
636
+ registerTool(server, "assert_not_visible", "Assert element matching selector is NOT visible. Returns { pass, message }. Polling until gone.", assertNotVisibleSchema, async (args) => {
637
+ const { selector, timeoutMs, intervalMs, deviceId, platform } = assertNotVisibleSchema.parse(args);
638
+ if (!appSession.isConnected(deviceId, platform)) return textContent({
639
+ type: "text",
640
+ text: "No React Native app connected."
641
+ });
642
+ const code = buildQuerySelectorEvalCode(selector);
643
+ const requestTimeout = Math.max(1e4, timeoutMs + 5e3);
644
+ try {
645
+ const result = await pollUntil(async () => {
646
+ const res = await appSession.sendRequest({
647
+ method: "eval",
648
+ params: { code }
649
+ }, requestTimeout, deviceId, platform);
650
+ if (res.error != null) return {
651
+ pass: false,
652
+ error: res.error
653
+ };
654
+ return { pass: res.result == null };
655
+ }, timeoutMs, intervalMs);
656
+ if (result.error) return textContent({
657
+ type: "text",
658
+ text: `Error: ${result.error}`
659
+ });
660
+ const pass = result.pass;
661
+ return jsonContent({
662
+ pass,
663
+ message: pass ? `PASS: element matching "${selector}" not found (as expected)` : `FAIL: element matching "${selector}" still visible`
664
+ });
665
+ } catch (err) {
666
+ return {
667
+ isError: true,
668
+ content: [{
669
+ type: "text",
670
+ text: `assert_not_visible failed: ${err instanceof Error ? err.message : String(err)}`
671
+ }]
672
+ };
673
+ }
674
+ });
675
+ registerTool(server, "assert_element_count", "Assert count of elements matching selector. expectedCount or minCount/maxCount. Returns { pass, actualCount, message }.", assertElementCountSchema, async (args) => {
676
+ const { selector, expectedCount, minCount, maxCount, timeoutMs, intervalMs, deviceId, platform } = assertElementCountSchema.parse(args);
677
+ if (!appSession.isConnected(deviceId, platform)) return textContent({
678
+ type: "text",
679
+ text: "No React Native app connected."
680
+ });
681
+ const code = buildQuerySelectorAllEvalCode(selector);
682
+ const requestTimeout = Math.max(1e4, timeoutMs + 5e3);
683
+ function checkCount(count) {
684
+ if (expectedCount != null) return count === expectedCount;
685
+ let ok = true;
686
+ if (minCount != null) ok = ok && count >= minCount;
687
+ if (maxCount != null) ok = ok && count <= maxCount;
688
+ return ok;
689
+ }
690
+ try {
691
+ const result = await pollUntil(async () => {
692
+ const res = await appSession.sendRequest({
693
+ method: "eval",
694
+ params: { code }
695
+ }, requestTimeout, deviceId, platform);
696
+ if (res.error != null) return {
697
+ pass: false,
698
+ actualCount: 0,
699
+ error: res.error
700
+ };
701
+ const actualCount = (Array.isArray(res.result) ? res.result : []).length;
702
+ return {
703
+ pass: checkCount(actualCount),
704
+ actualCount
705
+ };
706
+ }, timeoutMs, intervalMs);
707
+ if (result.error) return textContent({
708
+ type: "text",
709
+ text: `Error: ${result.error}`
710
+ });
711
+ const { pass, actualCount } = result;
712
+ const expected = expectedCount != null ? `exactly ${expectedCount}` : `${minCount != null ? `>=${minCount}` : ""}${minCount != null && maxCount != null ? " and " : ""}${maxCount != null ? `<=${maxCount}` : ""}`;
713
+ return jsonContent({
714
+ pass,
715
+ actualCount,
716
+ message: pass ? `PASS: found ${actualCount} elements (expected ${expected})` : `FAIL: found ${actualCount} elements (expected ${expected})`
717
+ });
718
+ } catch (err) {
719
+ return {
720
+ isError: true,
721
+ content: [{
722
+ type: "text",
723
+ text: `assert_element_count failed: ${err instanceof Error ? err.message : String(err)}`
724
+ }]
725
+ };
726
+ }
727
+ });
728
+ }
729
+
730
+ //#endregion
731
+ //#region src/tools/webview-evaluate-script.ts
732
+ /**
733
+ * MCP 도구: webview_evaluate_script
734
+ * 앱에 등록된 WebView 내부에서 임의의 JavaScript를 실행 (injectJavaScript)
735
+ * Babel 플러그인(babel-plugin-inject-testid)이 <WebView> 태그를 자동 감지하여
736
+ * registerWebView/unregisterWebView/onMessage를 주입하므로, 별도 수동 등록 없이
737
+ * evaluate_script로 getRegisteredWebViewIds()를 호출하면 사용 가능한 ID를 확인할 수 있다.
738
+ */
739
+ const schema$22 = z.object({
740
+ webViewId: z.string().describe("WebView id. Use evaluate_script getRegisteredWebViewIds() to discover."),
741
+ script: z.string().describe("JS to run in WebView (DOM query, click, etc). Must evaluate to value for result."),
742
+ deviceId: deviceParam,
743
+ platform: platformParam
744
+ });
745
+ function registerWebviewEvaluateScript(server, appSession) {
746
+ server.registerTool("webview_evaluate_script", {
747
+ description: "Run JS in in-app WebView. Get webViewId via getRegisteredWebViewIds(). Prefer over tap for DOM (faster, reliable). onMessage needed for return value.",
748
+ inputSchema: schema$22
749
+ }, async (args) => {
750
+ const { webViewId, script, deviceId, platform } = schema$22.parse(args);
751
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
752
+ type: "text",
753
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
754
+ }] };
755
+ const code = `(function(){ return __REACT_NATIVE_MCP__.evaluateInWebViewAsync(${JSON.stringify(webViewId)}, ${JSON.stringify(script)}); })();`;
756
+ try {
757
+ const res = await appSession.sendRequest({
758
+ method: "eval",
759
+ params: { code }
760
+ }, 15e3, deviceId, platform);
761
+ if (res.error != null) return { content: [{
762
+ type: "text",
763
+ text: `Error: ${res.error}`
764
+ }] };
765
+ const result = res.result;
766
+ if (result && result.ok === true && result.value !== void 0) return { content: [{
767
+ type: "text",
768
+ text: result.value
769
+ }] };
770
+ if (result && result.ok === false && result.error != null) return { content: [{
771
+ type: "text",
772
+ text: `WebView error: ${result.error}`
773
+ }] };
774
+ if (result && result.ok === true) return { content: [{
775
+ type: "text",
776
+ text: `OK: script executed in WebView "${webViewId}". (No result: app may not forward onMessage to handleWebViewMessage.)`
777
+ }] };
778
+ return { content: [{
779
+ type: "text",
780
+ text: result?.error ?? JSON.stringify(result ?? res.result)
781
+ }] };
782
+ } catch (err) {
783
+ return {
784
+ isError: true,
785
+ content: [{
786
+ type: "text",
787
+ text: `Request failed: ${err instanceof Error ? err.message : String(err)}`
788
+ }]
789
+ };
790
+ }
791
+ });
792
+ }
793
+
794
+ //#endregion
795
+ //#region src/tools/eval-code.ts
796
+ /**
797
+ * MCP 도구: evaluate_script
798
+ * Chrome DevTools MCP 스펙. function + args를 앱에서 실행 (WebSocket eval)
799
+ * @see docs/chrome-devtools-mcp-spec-alignment.md
800
+ */
801
+ const schema$21 = z.object({
802
+ function: z.string().describe("JS function string. E.g. \"() => measureView(uid)\". require() not available."),
803
+ args: z.array(z.unknown()).optional().describe("Arguments array for the function."),
804
+ deviceId: deviceParam,
805
+ platform: platformParam
806
+ });
807
+ function formatResult(value) {
808
+ if (value === void 0) return "undefined";
809
+ if (typeof value === "string") return value;
810
+ try {
811
+ return JSON.stringify(value, null, 2);
812
+ } catch {
813
+ return String(value);
814
+ }
815
+ }
816
+ /**
817
+ * evaluate_script: Chrome DevTools MCP와 동일 (function, args). 앱에 코드 전송 후 결과 반환.
818
+ */
819
+ function registerEvaluateScript(server, appSession) {
820
+ server.registerTool("evaluate_script", {
821
+ description: "Run JS in app context. function (string), args (array). Returns JSON result. Use measureView(uid) for tap/swipe coords.",
822
+ inputSchema: schema$21
823
+ }, async (args) => {
824
+ const { function: fnStr, args: fnArgs, deviceId, platform } = schema$21.parse(args);
825
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
826
+ type: "text",
827
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
828
+ }] };
829
+ const code = `(function(){try{var __f=(${fnStr});return __f.apply(null,${JSON.stringify(fnArgs ?? [])});}catch(e){return e.message||String(e);}})()`;
830
+ try {
831
+ const res = await appSession.sendRequest({
832
+ method: "eval",
833
+ params: { code }
834
+ }, 1e4, deviceId, platform);
835
+ if (res.error != null) return { content: [{
836
+ type: "text",
837
+ text: `Error: ${res.error}`
838
+ }] };
839
+ return { content: [{
840
+ type: "text",
841
+ text: formatResult(res.result)
842
+ }] };
843
+ } catch (err) {
844
+ return {
845
+ isError: true,
846
+ content: [{
847
+ type: "text",
848
+ text: `evaluate_script failed: ${err instanceof Error ? err.message : String(err)}`
849
+ }]
850
+ };
851
+ }
852
+ });
853
+ }
854
+
855
+ //#endregion
856
+ //#region src/tools/get-debugger-status.ts
857
+ /**
858
+ * MCP 도구: get_debugger_status
859
+ * 앱↔MCP 연결 상태 확인. 다중 디바이스: 연결된 전체 디바이스 목록 포함.
860
+ */
861
+ const schema$20 = z.object({
862
+ topInsetDp: z.number().optional().describe("Set Android top inset override (dp). Overrides ADB auto-detect. Persists for the connection."),
863
+ deviceId: z.string().optional().describe("Target device for topInsetDp override.")
864
+ });
865
+ function registerGetDebuggerStatus(server, appSession) {
866
+ server.registerTool("get_debugger_status", {
867
+ description: "MCP connection status. appConnected, devices list (deviceId, platform). Call first to see connected devices.",
868
+ inputSchema: schema$20
869
+ }, async (args) => {
870
+ const { topInsetDp, deviceId } = schema$20.parse(args ?? {});
871
+ if (topInsetDp != null) try {
872
+ appSession.setTopInsetDp(topInsetDp, deviceId, "android");
873
+ } catch {}
874
+ const appConnected = appSession.isConnected();
875
+ const devices = appSession.getConnectedDevices();
876
+ const status = {
877
+ appConnected,
878
+ devices
879
+ };
880
+ if (appConnected && devices.length > 0) try {
881
+ const info = (await appSession.sendRequest({
882
+ method: "eval",
883
+ params: { code: "(function(){ var M = typeof __REACT_NATIVE_MCP__ !== \"undefined\" ? __REACT_NATIVE_MCP__ : null; return M && M.getScreenInfo ? M.getScreenInfo() : null; })();" }
884
+ }, 5e3)).result;
885
+ if (info && typeof info === "object" && info.orientation) {
886
+ status.orientation = info.orientation;
887
+ if (info.window) status.window = info.window;
888
+ }
889
+ } catch {}
890
+ const text = [
891
+ `appConnected: ${appConnected}`,
892
+ `devices: ${devices.length > 0 ? devices.map((d) => `${d.deviceId}(${d.platform}${d.deviceName ? ", " + d.deviceName : ""}${d.platform === "android" ? `, topInset=${d.topInsetDp}dp` : ""})`).join(", ") : "none"}`,
893
+ status.orientation != null ? `orientation: ${status.orientation}` : ""
894
+ ].filter(Boolean).join("\n");
895
+ return { content: [{
896
+ type: "text",
897
+ text: JSON.stringify(status, null, 2)
898
+ }, {
899
+ type: "text",
900
+ text
901
+ }] };
902
+ });
903
+ }
904
+
905
+ //#endregion
906
+ //#region src/tools/list-console-messages.ts
907
+ /**
908
+ * MCP 도구: list_console_messages, clear_console_messages
909
+ * nativeLoggingHook을 통해 캡처된 콘솔 로그를 조회/초기화.
910
+ * runtime.js의 getConsoleLogs / clearConsoleLogs를 eval로 호출.
911
+ */
912
+ const LEVEL_NAMES = {
913
+ 0: "log",
914
+ 1: "info",
915
+ 2: "warn",
916
+ 3: "error"
917
+ };
918
+ const listSchema$3 = z.object({
919
+ level: z.enum([
920
+ "log",
921
+ "info",
922
+ "warn",
923
+ "error"
924
+ ]).optional().describe("Filter by level. Omit for all."),
925
+ since: z.number().optional().describe("Only logs after timestamp (ms)."),
926
+ limit: z.number().optional().describe("Max logs. Default 100."),
927
+ deviceId: deviceParam,
928
+ platform: platformParam
929
+ });
930
+ const clearSchema$4 = z.object({
931
+ deviceId: deviceParam,
932
+ platform: platformParam
933
+ });
934
+ function registerListConsoleMessages(server, appSession) {
935
+ const s = server;
936
+ s.registerTool("list_console_messages", {
937
+ description: "List captured console logs. Filter by level, since, limit. Levels: log, info, warn, error.",
938
+ inputSchema: listSchema$3
939
+ }, async (args) => {
940
+ const parsed = listSchema$3.safeParse(args ?? {});
941
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
942
+ const platform = parsed.success ? parsed.data.platform : void 0;
943
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
944
+ type: "text",
945
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
946
+ }] };
947
+ const options = {};
948
+ if (parsed.success) {
949
+ if (parsed.data.level != null) options.level = parsed.data.level;
950
+ if (parsed.data.since != null) options.since = parsed.data.since;
951
+ if (parsed.data.limit != null) options.limit = parsed.data.limit;
952
+ }
953
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.getConsoleLogs ? __REACT_NATIVE_MCP__.getConsoleLogs(${JSON.stringify(options)}) : []; })();`;
954
+ try {
955
+ const res = await appSession.sendRequest({
956
+ method: "eval",
957
+ params: { code }
958
+ }, 1e4, deviceId, platform);
959
+ if (res.error != null) return { content: [{
960
+ type: "text",
961
+ text: `Error: ${res.error}`
962
+ }] };
963
+ const list = Array.isArray(res.result) ? res.result : [];
964
+ if (list.length === 0) return { content: [{
965
+ type: "text",
966
+ text: "No console messages."
967
+ }] };
968
+ const lines = list.map((entry) => {
969
+ const levelName = LEVEL_NAMES[entry.level ?? 0] ?? String(entry.level);
970
+ return `[${entry.id}] ${levelName}: ${entry.message ?? ""} (${entry.timestamp ?? ""})`;
971
+ });
972
+ return { content: [{
973
+ type: "text",
974
+ text: `${list.length} message(s):\n${lines.join("\n")}`
975
+ }] };
976
+ } catch (err) {
977
+ return {
978
+ isError: true,
979
+ content: [{
980
+ type: "text",
981
+ text: `list_console_messages failed: ${err instanceof Error ? err.message : String(err)}`
982
+ }]
983
+ };
984
+ }
985
+ });
986
+ s.registerTool("clear_console_messages", {
987
+ description: "Clear all captured console messages from the buffer.",
988
+ inputSchema: clearSchema$4
989
+ }, async (args) => {
990
+ const parsed = clearSchema$4.safeParse(args ?? {});
991
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
992
+ const platform = parsed.success ? parsed.data.platform : void 0;
993
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
994
+ type: "text",
995
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
996
+ }] };
997
+ const code = `(function(){ if (typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.clearConsoleLogs) { __REACT_NATIVE_MCP__.clearConsoleLogs(); return true; } return false; })();`;
998
+ try {
999
+ const res = await appSession.sendRequest({
1000
+ method: "eval",
1001
+ params: { code }
1002
+ }, 1e4, deviceId, platform);
1003
+ if (res.error != null) return { content: [{
1004
+ type: "text",
1005
+ text: `Error: ${res.error}`
1006
+ }] };
1007
+ return { content: [{
1008
+ type: "text",
1009
+ text: "Console messages cleared."
1010
+ }] };
1011
+ } catch (err) {
1012
+ return {
1013
+ isError: true,
1014
+ content: [{
1015
+ type: "text",
1016
+ text: `clear_console_messages failed: ${err instanceof Error ? err.message : String(err)}`
1017
+ }]
1018
+ };
1019
+ }
1020
+ });
1021
+ }
1022
+
1023
+ //#endregion
1024
+ //#region src/tools/list-network-requests.ts
1025
+ /**
1026
+ * MCP 도구: list_network_requests, clear_network_requests
1027
+ * XHR/fetch monkey-patch를 통해 캡처된 네트워크 요청을 조회/초기화.
1028
+ * runtime.js의 getNetworkRequests / clearNetworkRequests를 eval로 호출.
1029
+ */
1030
+ const listSchema$2 = z.object({
1031
+ url: z.string().optional().describe("URL substring filter."),
1032
+ method: z.string().optional().describe("HTTP method filter."),
1033
+ status: z.number().optional().describe("Status code filter."),
1034
+ since: z.number().optional().describe("Only requests after timestamp (ms)."),
1035
+ limit: z.number().optional().describe("Max requests. Default 50."),
1036
+ deviceId: deviceParam,
1037
+ platform: platformParam
1038
+ });
1039
+ const clearSchema$3 = z.object({
1040
+ deviceId: deviceParam,
1041
+ platform: platformParam
1042
+ });
1043
+ function registerListNetworkRequests(server, appSession) {
1044
+ const s = server;
1045
+ s.registerTool("list_network_requests", {
1046
+ description: "List captured XHR/fetch requests. Filter by url, method, status, since, limit. Returns request/response details.",
1047
+ inputSchema: listSchema$2
1048
+ }, async (args) => {
1049
+ const parsed = listSchema$2.safeParse(args ?? {});
1050
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
1051
+ const platform = parsed.success ? parsed.data.platform : void 0;
1052
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
1053
+ type: "text",
1054
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
1055
+ }] };
1056
+ const options = {};
1057
+ if (parsed.success) {
1058
+ if (parsed.data.url != null) options.url = parsed.data.url;
1059
+ if (parsed.data.method != null) options.method = parsed.data.method;
1060
+ if (parsed.data.status != null) options.status = parsed.data.status;
1061
+ if (parsed.data.since != null) options.since = parsed.data.since;
1062
+ if (parsed.data.limit != null) options.limit = parsed.data.limit;
1063
+ }
1064
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.getNetworkRequests ? __REACT_NATIVE_MCP__.getNetworkRequests(${JSON.stringify(options)}) : []; })();`;
1065
+ try {
1066
+ const res = await appSession.sendRequest({
1067
+ method: "eval",
1068
+ params: { code }
1069
+ }, 1e4, deviceId, platform);
1070
+ if (res.error != null) return { content: [{
1071
+ type: "text",
1072
+ text: `Error: ${res.error}`
1073
+ }] };
1074
+ const list = Array.isArray(res.result) ? res.result : [];
1075
+ if (list.length === 0) return { content: [{
1076
+ type: "text",
1077
+ text: "No network requests."
1078
+ }] };
1079
+ const lines = list.map((entry) => {
1080
+ const status = entry.status != null ? String(entry.status) : "-";
1081
+ const duration = entry.duration != null ? `${entry.duration}ms` : "pending";
1082
+ const error = entry.error ? ` [${entry.error}]` : "";
1083
+ return `[${entry.id}] ${entry.method} ${entry.url} → ${status} (${duration})${error}`;
1084
+ });
1085
+ return { content: [{
1086
+ type: "text",
1087
+ text: `${list.length} request(s):\n${lines.join("\n")}`
1088
+ }] };
1089
+ } catch (err) {
1090
+ return {
1091
+ isError: true,
1092
+ content: [{
1093
+ type: "text",
1094
+ text: `list_network_requests failed: ${err instanceof Error ? err.message : String(err)}`
1095
+ }]
1096
+ };
1097
+ }
1098
+ });
1099
+ s.registerTool("clear_network_requests", {
1100
+ description: "Clear all captured network requests from the buffer.",
1101
+ inputSchema: clearSchema$3
1102
+ }, async (args) => {
1103
+ const parsed = clearSchema$3.safeParse(args ?? {});
1104
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
1105
+ const platform = parsed.success ? parsed.data.platform : void 0;
1106
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
1107
+ type: "text",
1108
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
1109
+ }] };
1110
+ const code = `(function(){ if (typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.clearNetworkRequests) { __REACT_NATIVE_MCP__.clearNetworkRequests(); return true; } return false; })();`;
1111
+ try {
1112
+ const res = await appSession.sendRequest({
1113
+ method: "eval",
1114
+ params: { code }
1115
+ }, 1e4, deviceId, platform);
1116
+ if (res.error != null) return { content: [{
1117
+ type: "text",
1118
+ text: `Error: ${res.error}`
1119
+ }] };
1120
+ return { content: [{
1121
+ type: "text",
1122
+ text: "Network requests cleared."
1123
+ }] };
1124
+ } catch (err) {
1125
+ return {
1126
+ isError: true,
1127
+ content: [{
1128
+ type: "text",
1129
+ text: `clear_network_requests failed: ${err instanceof Error ? err.message : String(err)}`
1130
+ }]
1131
+ };
1132
+ }
1133
+ });
1134
+ }
1135
+
1136
+ //#endregion
1137
+ //#region src/tools/take-screenshot.ts
1138
+ /**
1139
+ * MCP 도구: take_screenshot
1140
+ * 네이티브 모듈 없이 호스트 CLI(ADB / simctl)로 스크린샷 캡처.
1141
+ * 항상 JPEG 80% + 720p. 좌표 보정용 원본 포인트 해상도 포함.
1142
+ */
1143
+ const MAX_HEIGHT = 720;
1144
+ const JPEG_QUALITY = 80;
1145
+ const schema$19 = z.object({
1146
+ platform: z.enum(["android", "ios"]).describe("android: adb. ios: simctl (simulator only)."),
1147
+ filePath: z.string().optional().describe("Path to save screenshot.")
1148
+ });
1149
+ const PNG_SIGNATURE = Buffer.from([
1150
+ 137,
1151
+ 80,
1152
+ 78,
1153
+ 71,
1154
+ 13,
1155
+ 10,
1156
+ 26,
1157
+ 10
1158
+ ]);
1159
+ function isValidPng(buf) {
1160
+ return buf.length >= PNG_SIGNATURE.length && buf.subarray(0, 8).equals(PNG_SIGNATURE);
1161
+ }
1162
+ /** Android: adb exec-out screencap -p 로 raw PNG 수신. */
1163
+ async function captureAndroid() {
1164
+ const png = await runCommand("adb", [
1165
+ "exec-out",
1166
+ "screencap",
1167
+ "-p"
1168
+ ], { timeoutMs: 1e4 });
1169
+ if (!isValidPng(png)) throw new Error("adb screencap produced invalid PNG. Try again or check device.");
1170
+ return png;
1171
+ }
1172
+ /** iOS 시뮬레이터: simctl io booted screenshot → 파일 읽기. */
1173
+ async function captureIos() {
1174
+ const path = join(tmpdir(), `rn-mcp-screenshot-${Date.now()}.png`);
1175
+ await runCommand("xcrun", [
1176
+ "simctl",
1177
+ "io",
1178
+ "booted",
1179
+ "screenshot",
1180
+ path
1181
+ ], { timeoutMs: 1e4 });
1182
+ try {
1183
+ return await readFile(path);
1184
+ } finally {
1185
+ await unlink(path).catch(() => {});
1186
+ }
1187
+ }
1188
+ /** 720p JPEG 80%로 변환. 원본 포인트 해상도와 출력 크기를 함께 반환. */
1189
+ async function processImage(png, platform, runtimePixelRatio) {
1190
+ const sharp = (await import("sharp")).default;
1191
+ const metadata = await sharp(png).metadata();
1192
+ const rawWidth = metadata.width || 0;
1193
+ const rawHeight = metadata.height || 0;
1194
+ let scale;
1195
+ if (runtimePixelRatio != null) scale = runtimePixelRatio;
1196
+ else if (platform === "android") scale = await getAndroidScale();
1197
+ else {
1198
+ const density = metadata.density || 72;
1199
+ scale = Math.round(density / 72) || 1;
1200
+ }
1201
+ const pointSize = {
1202
+ width: Math.round(rawWidth / scale),
1203
+ height: Math.round(rawHeight / scale)
1204
+ };
1205
+ const buffer = await sharp(png).resize({
1206
+ height: MAX_HEIGHT,
1207
+ fit: "inside"
1208
+ }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
1209
+ const outMeta = await sharp(buffer).metadata();
1210
+ return {
1211
+ buffer,
1212
+ pointSize,
1213
+ outputSize: {
1214
+ width: outMeta.width || 0,
1215
+ height: outMeta.height || 0
1216
+ }
1217
+ };
1218
+ }
1219
+ function registerTakeScreenshot(server, appSession) {
1220
+ server.registerTool("take_screenshot", {
1221
+ description: "Capture device/simulator screen. JPEG 720p. Returns point size for coords. Prefer assert_text/assert_visible (screenshots use vision tokens).",
1222
+ inputSchema: schema$19
1223
+ }, async (args) => {
1224
+ const { platform, filePath } = schema$19.parse(args);
1225
+ try {
1226
+ const png = platform === "android" ? await captureAndroid() : await captureIos();
1227
+ if (!isValidPng(png)) throw new Error("Capture produced invalid PNG.");
1228
+ const { buffer, pointSize, outputSize } = await processImage(png, platform, appSession.getPixelRatio(void 0, platform));
1229
+ if (filePath) await writeFile(filePath, buffer);
1230
+ const base64 = buffer.toString("base64");
1231
+ const scaleX = (pointSize.width / outputSize.width).toFixed(4);
1232
+ const scaleY = (pointSize.height / outputSize.height).toFixed(4);
1233
+ return { content: [{
1234
+ type: "text",
1235
+ text: `Screenshot captured (${platform}).${filePath ? ` Saved to ${filePath}.` : ""} Device point size: ${pointSize.width}x${pointSize.height}. Screenshot size: ${outputSize.width}x${outputSize.height}. Coordinate scale: point_x = screenshot_x × ${scaleX}, point_y = screenshot_y × ${scaleY}.`
1236
+ }, {
1237
+ type: "image",
1238
+ data: base64,
1239
+ mimeType: "image/jpeg"
1240
+ }] };
1241
+ } catch (err) {
1242
+ return {
1243
+ isError: true,
1244
+ content: [{
1245
+ type: "text",
1246
+ text: `Screenshot failed: ${err instanceof Error ? err.message : String(err)}. Ensure ${platform === "android" ? "adb devices has a device" : "iOS Simulator is booted (xcrun simctl list devices)"}.`
1247
+ }]
1248
+ };
1249
+ }
1250
+ });
1251
+ }
1252
+
1253
+ //#endregion
1254
+ //#region src/tools/take-snapshot.ts
1255
+ /**
1256
+ * MCP 도구: take_snapshot
1257
+ * Chrome DevTools MCP 스펙 정렬. a11y 대신 Fiber 컴포넌트 트리(타입/testID/자식) 기반 스냅샷.
1258
+ * querySelector 대체: 트리에서 ScrollView, FlatList, Text 등 타입·uid로 요소 탐색.
1259
+ */
1260
+ const schema$18 = z.object({
1261
+ maxDepth: z.number().int().min(1).max(100).optional().describe("Max tree depth. Default 30."),
1262
+ deviceId: deviceParam,
1263
+ platform: platformParam
1264
+ });
1265
+ function registerTakeSnapshot(server, appSession) {
1266
+ server.registerTool("take_snapshot", {
1267
+ description: "Capture component tree for uids. Returns uid, type, testID, text. uid = testID or path. Then measureView(uid) → tap/swipe.",
1268
+ inputSchema: schema$18
1269
+ }, async (args) => {
1270
+ const parsed = schema$18.safeParse(args ?? {});
1271
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
1272
+ const platform = parsed.success ? parsed.data.platform : void 0;
1273
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
1274
+ type: "text",
1275
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
1276
+ }] };
1277
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.getComponentTree ? __REACT_NATIVE_MCP__.getComponentTree({ maxDepth: ${parsed.success && parsed.data.maxDepth != null ? parsed.data.maxDepth : 30} }) : null; })();`;
1278
+ try {
1279
+ const res = await appSession.sendRequest({
1280
+ method: "eval",
1281
+ params: { code }
1282
+ }, 1e4, deviceId, platform);
1283
+ if (res.error != null) return { content: [{
1284
+ type: "text",
1285
+ text: `Error: ${res.error}`
1286
+ }] };
1287
+ const tree = res.result;
1288
+ return { content: [{
1289
+ type: "text",
1290
+ text: tree == null ? "Snapshot unavailable (DevTools hook or fiber root missing)." : JSON.stringify(tree, null, 2)
1291
+ }] };
1292
+ } catch (err) {
1293
+ return {
1294
+ isError: true,
1295
+ content: [{
1296
+ type: "text",
1297
+ text: `take_snapshot failed: ${err instanceof Error ? err.message : String(err)}`
1298
+ }]
1299
+ };
1300
+ }
1301
+ });
1302
+ }
1303
+
1304
+ //#endregion
1305
+ //#region src/tools/accessibility-audit.ts
1306
+ /**
1307
+ * MCP 도구: accessibility_audit
1308
+ * Fiber 트리 순회로 접근성(a11y) 규칙 위반 자동 검출.
1309
+ * 규칙: pressable-needs-label, image-needs-alt, touch-target-size, missing-role.
1310
+ * text-contrast는 미구현(어려움: RN processColor 숫자화·스타일 병합으로 대비비 계산 불가).
1311
+ */
1312
+ const schema$17 = z.object({
1313
+ maxDepth: z.number().int().min(1).max(100).optional().describe("Max tree depth. Default 999."),
1314
+ deviceId: deviceParam,
1315
+ platform: platformParam
1316
+ });
1317
+ function registerAccessibilityAudit(server, appSession) {
1318
+ server.registerTool("accessibility_audit", {
1319
+ description: "Run a11y audit on RN tree. Returns violations: rule, selector, severity, message. Rules: pressable-needs-label, image-needs-alt, touch-target-size, missing-role.",
1320
+ inputSchema: schema$17
1321
+ }, async (args) => {
1322
+ const parsed = schema$17.safeParse(args ?? {});
1323
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
1324
+ const platform = parsed.success ? parsed.data.platform : void 0;
1325
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
1326
+ type: "text",
1327
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
1328
+ }] };
1329
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.getAccessibilityAudit ? __REACT_NATIVE_MCP__.getAccessibilityAudit({ maxDepth: ${parsed.success && parsed.data.maxDepth != null ? parsed.data.maxDepth : 999} }) : []; })();`;
1330
+ try {
1331
+ const res = await appSession.sendRequest({
1332
+ method: "eval",
1333
+ params: { code }
1334
+ }, 15e3, deviceId, platform);
1335
+ if (res.error != null) return { content: [{
1336
+ type: "text",
1337
+ text: `Error: ${res.error}`
1338
+ }] };
1339
+ const violations = Array.isArray(res.result) ? res.result : [];
1340
+ return { content: [{
1341
+ type: "text",
1342
+ text: JSON.stringify(violations, null, 2)
1343
+ }] };
1344
+ } catch (err) {
1345
+ return {
1346
+ isError: true,
1347
+ content: [{
1348
+ type: "text",
1349
+ text: `accessibility_audit failed: ${err instanceof Error ? err.message : String(err)}`
1350
+ }]
1351
+ };
1352
+ }
1353
+ });
1354
+ }
1355
+
1356
+ //#endregion
1357
+ //#region src/tools/type-text.ts
1358
+ /**
1359
+ * MCP 도구: type_text
1360
+ * TextInput에 텍스트 입력. onChangeText 호출 + setNativeProps로 네이티브 값 동기화.
1361
+ */
1362
+ const schema$16 = z.object({
1363
+ uid: z.string().describe("testID or path of TextInput. Get from query_selector first."),
1364
+ text: z.string().describe("Text to type."),
1365
+ deviceId: deviceParam,
1366
+ platform: platformParam
1367
+ });
1368
+ function registerTypeText(server, appSession) {
1369
+ server.registerTool("type_text", {
1370
+ description: "Type text into TextInput by uid (from query_selector). Supports Unicode. Use instead of input_text for non-ASCII.",
1371
+ inputSchema: schema$16
1372
+ }, async (args) => {
1373
+ const { uid, text: inputText, deviceId, platform } = schema$16.parse(args);
1374
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
1375
+ type: "text",
1376
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
1377
+ }] };
1378
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.typeText ? __REACT_NATIVE_MCP__.typeText(${JSON.stringify(uid)}, ${JSON.stringify(inputText)}) : { ok: false, error: 'typeText not available' }; })();`;
1379
+ try {
1380
+ const res = await appSession.sendRequest({
1381
+ method: "eval",
1382
+ params: { code }
1383
+ }, 1e4, deviceId, platform);
1384
+ if (res.error != null) return { content: [{
1385
+ type: "text",
1386
+ text: `Error: ${res.error}`
1387
+ }] };
1388
+ const out = res.result;
1389
+ return { content: [{
1390
+ type: "text",
1391
+ text: (out && typeof out.ok === "boolean" ? out.ok : false) ? `typed "${inputText}" into ${uid}` : out && out.error ? out.error : "typeText failed"
1392
+ }] };
1393
+ } catch (err) {
1394
+ return {
1395
+ isError: true,
1396
+ content: [{
1397
+ type: "text",
1398
+ text: `type_text failed: ${err instanceof Error ? err.message : String(err)}`
1399
+ }]
1400
+ };
1401
+ }
1402
+ });
1403
+ }
1404
+
1405
+ //#endregion
1406
+ //#region src/tools/switch-keyboard.ts
1407
+ /**
1408
+ * MCP 도구: switch_keyboard
1409
+ * iOS 시뮬레이터 / Android 에뮬레이터 키보드 전환.
1410
+ * input_text는 HID 키코드 기반이라 현재 키보드 언어에 의존 — 이 도구로 영문 전환 후 사용.
1411
+ */
1412
+ const schema$15 = z.object({
1413
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
1414
+ action: z.enum([
1415
+ "list",
1416
+ "get",
1417
+ "switch"
1418
+ ]).describe("list: available keyboards. get: current. switch: toggle (iOS) or set IME (Android)."),
1419
+ keyboard_id: z.string().optional().describe("Android switch only. IME ID. Use action=list to see IDs.")
1420
+ });
1421
+ async function iosListKeyboards() {
1422
+ return (await runCommand("xcrun", [
1423
+ "simctl",
1424
+ "spawn",
1425
+ "booted",
1426
+ "defaults",
1427
+ "read",
1428
+ "-g",
1429
+ "AppleKeyboards"
1430
+ ], { timeoutMs: 1e4 })).toString("utf8").trim();
1431
+ }
1432
+ async function iosSwitchKeyboard() {
1433
+ await runCommand("osascript", ["-e", [
1434
+ "tell application \"Simulator\" to activate",
1435
+ "delay 0.3",
1436
+ "tell application \"System Events\"",
1437
+ " key code 49 using control down",
1438
+ "end tell"
1439
+ ].join("\n")], { timeoutMs: 1e4 });
1440
+ return "Keyboard toggled via Ctrl+Space on iOS Simulator. If it did not work, ensure Simulator has multiple keyboards configured (Settings → General → Keyboard → Keyboards).";
1441
+ }
1442
+ async function androidListKeyboards() {
1443
+ return (await runCommand("adb", [
1444
+ "shell",
1445
+ "ime",
1446
+ "list",
1447
+ "-s"
1448
+ ], { timeoutMs: 1e4 })).toString("utf8").trim();
1449
+ }
1450
+ async function androidGetKeyboard() {
1451
+ return (await runCommand("adb", [
1452
+ "shell",
1453
+ "settings",
1454
+ "get",
1455
+ "secure",
1456
+ "default_input_method"
1457
+ ], { timeoutMs: 1e4 })).toString("utf8").trim();
1458
+ }
1459
+ async function androidSwitchKeyboard(keyboardId) {
1460
+ return (await runCommand("adb", [
1461
+ "shell",
1462
+ "ime",
1463
+ "set",
1464
+ keyboardId
1465
+ ], { timeoutMs: 1e4 })).toString("utf8").trim();
1466
+ }
1467
+ function registerSwitchKeyboard(server) {
1468
+ server.registerTool("switch_keyboard", {
1469
+ description: "Switch keyboard on simulator/emulator. Use before input_text for correct layout. iOS: Ctrl+Space. Android: IME by ID.",
1470
+ inputSchema: schema$15
1471
+ }, async (args) => {
1472
+ const { platform, action, keyboard_id } = schema$15.parse(args);
1473
+ try {
1474
+ if (platform === "ios") switch (action) {
1475
+ case "list": return { content: [{
1476
+ type: "text",
1477
+ text: `Registered keyboards on booted iOS Simulator:\n${await iosListKeyboards()}`
1478
+ }] };
1479
+ case "get": return { content: [{
1480
+ type: "text",
1481
+ text: `iOS Simulator does not expose the currently active keyboard directly. Registered keyboards:\n${await iosListKeyboards()}\n\nUse action=switch to toggle between them (Ctrl+Space).`
1482
+ }] };
1483
+ case "switch": return { content: [{
1484
+ type: "text",
1485
+ text: await iosSwitchKeyboard()
1486
+ }] };
1487
+ }
1488
+ else switch (action) {
1489
+ case "list": return { content: [{
1490
+ type: "text",
1491
+ text: `Available Android IMEs:\n${await androidListKeyboards()}`
1492
+ }] };
1493
+ case "get": return { content: [{
1494
+ type: "text",
1495
+ text: `Current Android IME: ${await androidGetKeyboard()}`
1496
+ }] };
1497
+ case "switch":
1498
+ if (!keyboard_id) return { content: [{
1499
+ type: "text",
1500
+ text: "keyboard_id is required for Android switch. Use action=list to see available keyboards."
1501
+ }] };
1502
+ return { content: [{
1503
+ type: "text",
1504
+ text: `Android IME switched: ${await androidSwitchKeyboard(keyboard_id)}`
1505
+ }] };
1506
+ }
1507
+ } catch (err) {
1508
+ return {
1509
+ isError: true,
1510
+ content: [{
1511
+ type: "text",
1512
+ text: `switch_keyboard failed: ${err instanceof Error ? err.message : String(err)}`
1513
+ }]
1514
+ };
1515
+ }
1516
+ });
1517
+ }
1518
+
1519
+ //#endregion
1520
+ //#region src/tools/idb-utils.ts
1521
+ /**
1522
+ * idb (iOS Development Bridge) 공유 유틸리티
1523
+ * UDID 자동 해석, idb 설치 확인, 명령 실행 래퍼.
1524
+ */
1525
+ let _idbAvailable = null;
1526
+ async function checkIdbAvailable() {
1527
+ if (_idbAvailable != null) return _idbAvailable;
1528
+ try {
1529
+ await runCommand("which", ["idb"], { timeoutMs: 3e3 });
1530
+ _idbAvailable = true;
1531
+ } catch {
1532
+ _idbAvailable = false;
1533
+ }
1534
+ return _idbAvailable;
1535
+ }
1536
+ async function listIdbTargets() {
1537
+ const lines = (await runCommand("idb", ["list-targets", "--json"], { timeoutMs: 1e4 })).toString("utf8").split("\n").filter((l) => l.trim().length > 0);
1538
+ const targets = [];
1539
+ for (const line of lines) try {
1540
+ targets.push(JSON.parse(line));
1541
+ } catch {}
1542
+ return targets;
1543
+ }
1544
+ async function resolveUdid(udid) {
1545
+ if (udid != null && udid !== "") return udid;
1546
+ const booted = (await listIdbTargets()).filter((t) => t.state === "Booted" && t.type === "simulator");
1547
+ if (booted.length === 0) throw new Error("No booted iOS simulator found. Boot one with: xcrun simctl boot \"<device name>\"");
1548
+ if (booted.length > 1) {
1549
+ const list = booted.map((t) => ` ${t.name} (${t.udid})`).join("\n");
1550
+ throw new Error(`Multiple booted simulators found. Specify udid parameter.\n${list}\nUse list_devices(platform="ios") to see all devices.`);
1551
+ }
1552
+ return booted[0].udid;
1553
+ }
1554
+ async function runIdbCommand(subcommand, udid, options) {
1555
+ return (await runCommand("idb", [
1556
+ ...subcommand,
1557
+ "--udid",
1558
+ udid
1559
+ ], { timeoutMs: options?.timeoutMs ?? 1e4 })).toString("utf8").trim();
1560
+ }
1561
+ function idbNotInstalledError() {
1562
+ return {
1563
+ isError: true,
1564
+ content: [{
1565
+ type: "text",
1566
+ text: [
1567
+ "idb (iOS Development Bridge) is not installed.",
1568
+ "",
1569
+ "Install:",
1570
+ " brew tap facebook/fb",
1571
+ " brew install idb-companion",
1572
+ " pip3 install fb-idb",
1573
+ "",
1574
+ "Verify: idb list-targets",
1575
+ "Docs: https://fbidb.io/docs/installation/"
1576
+ ].join("\n")
1577
+ }]
1578
+ };
1579
+ }
1580
+
1581
+ //#endregion
1582
+ //#region src/tools/ios-landscape.ts
1583
+ /**
1584
+ * iOS 시뮬레이터 좌표 변환 유틸리티.
1585
+ *
1586
+ * idb ui tap/swipe는 GraphicsOrientation=1 (portrait 0°) 기준 좌표를 기대하므로,
1587
+ * 다른 orientation에서는 RN 좌표 → portrait 좌표로 변환해야 한다.
1588
+ *
1589
+ * GraphicsOrientation (com.apple.backboardd):
1590
+ * 1 = Portrait 0° → 변환 없음 (x, y)
1591
+ * 2 = Portrait 180° → 변환: (W - x, H - y)
1592
+ * 3 = Landscape A (270°) → 변환: (y, W - x)
1593
+ * 4 = Landscape B (90°) → 변환: (H - y, x)
1594
+ *
1595
+ * W = window width, H = window height (현재 orientation 기준)
1596
+ */
1597
+ const execFileAsync = promisify(execFile);
1598
+ const SCREEN_INFO_CODE = "(function(){ var M = typeof __REACT_NATIVE_MCP__ !== \"undefined\" ? __REACT_NATIVE_MCP__ : null; return M && M.getScreenInfo ? M.getScreenInfo() : null; })();";
1599
+ /**
1600
+ * 앱 런타임 + 시뮬레이터에서 orientation 정보를 수집한다.
1601
+ * orientation이 0° (portrait)가 아닐 때만 GraphicsOrientation을 조회.
1602
+ * @param orientationOverride e2e.yaml config.orientation 등에서 사용자가 강제 지정한 GraphicsOrientation 값 (1~4). 지정 시 xcrun 자동감지를 건너뛰고 이 값을 사용한다.
1603
+ */
1604
+ async function getIOSOrientationInfo(appSession, deviceId, platform, udid, orientationOverride) {
1605
+ const defaultInfo = {
1606
+ graphicsOrientation: 1,
1607
+ width: 0,
1608
+ height: 0
1609
+ };
1610
+ if (platform !== "ios") return defaultInfo;
1611
+ let width = 0;
1612
+ let height = 0;
1613
+ try {
1614
+ const info = (await appSession.sendRequest({
1615
+ method: "eval",
1616
+ params: { code: SCREEN_INFO_CODE }
1617
+ }, 3e3, deviceId, platform)).result;
1618
+ if (info && typeof info === "object") {
1619
+ width = info.window?.width ?? 0;
1620
+ height = info.window?.height ?? 0;
1621
+ }
1622
+ } catch {
1623
+ return defaultInfo;
1624
+ }
1625
+ let graphicsOrientation = 1;
1626
+ if (orientationOverride != null && orientationOverride >= 1 && orientationOverride <= 4) graphicsOrientation = orientationOverride;
1627
+ else try {
1628
+ const { stdout } = await execFileAsync("xcrun", [
1629
+ "simctl",
1630
+ "spawn",
1631
+ udid,
1632
+ "defaults",
1633
+ "read",
1634
+ "com.apple.backboardd"
1635
+ ], { timeout: 3e3 });
1636
+ const match = stdout.match(/GraphicsOrientation\s*=\s*(\d+)/);
1637
+ if (match?.[1]) graphicsOrientation = parseInt(match[1], 10);
1638
+ } catch {}
1639
+ return {
1640
+ graphicsOrientation,
1641
+ width,
1642
+ height
1643
+ };
1644
+ }
1645
+ /**
1646
+ * RN 좌표 → idb portrait 좌표 변환.
1647
+ * GraphicsOrientation=1 이면 그대로 반환.
1648
+ * width/height=0 이면 변환 공식이 음수를 만들 수 있으므로 변환 없이 반환.
1649
+ */
1650
+ function transformForIdb(x, y, info) {
1651
+ if (info.width <= 0 || info.height <= 0) return {
1652
+ x,
1653
+ y
1654
+ };
1655
+ switch (info.graphicsOrientation) {
1656
+ case 2: return {
1657
+ x: info.width - x,
1658
+ y: info.height - y
1659
+ };
1660
+ case 3: return {
1661
+ x: y,
1662
+ y: info.width - x
1663
+ };
1664
+ case 4: return {
1665
+ x: info.height - y,
1666
+ y: x
1667
+ };
1668
+ default: return {
1669
+ x,
1670
+ y
1671
+ };
1672
+ }
1673
+ }
1674
+
1675
+ //#endregion
1676
+ //#region src/tools/tap.ts
1677
+ /**
1678
+ * MCP 도구: tap
1679
+ * iOS(idb) / Android(adb) 좌표 탭 통합.
1680
+ */
1681
+ const schema$14 = z.object({
1682
+ platform: z.enum(["ios", "android"]).describe("ios or android."),
1683
+ x: z.number().describe("X in points (dp). Pixels auto on Android."),
1684
+ y: z.number().describe("Y in points (dp). Pixels auto on Android."),
1685
+ duration: z.number().optional().describe("Hold ms for long press. Omit for tap."),
1686
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find."),
1687
+ iosOrientation: z.number().optional().describe("iOS orientation 1-4. Portrait=1,2; Landscape=3,4. Skips auto-detect.")
1688
+ });
1689
+ function registerTap(server, appSession) {
1690
+ server.registerTool("tap", {
1691
+ description: "Tap at (x,y) in points. Long press via duration (ms). Use after query_selector; verify with assert_text.",
1692
+ inputSchema: schema$14
1693
+ }, async (args) => {
1694
+ const { platform, x, y, duration, deviceId, iosOrientation } = schema$14.parse(args);
1695
+ const isLongPress = duration != null && duration > 0;
1696
+ const action = isLongPress ? "Long-pressed" : "Tapped";
1697
+ try {
1698
+ if (platform === "ios") {
1699
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
1700
+ const udid = await resolveUdid(deviceId);
1701
+ const t = transformForIdb(x, y, await getIOSOrientationInfo(appSession, deviceId, platform, udid, iosOrientation));
1702
+ const ix = Math.round(t.x);
1703
+ const iy = Math.round(t.y);
1704
+ const cmd = [
1705
+ "ui",
1706
+ "tap",
1707
+ String(ix),
1708
+ String(iy)
1709
+ ];
1710
+ if (isLongPress) cmd.push("--duration", String(duration / 1e3));
1711
+ await runIdbCommand(cmd, udid);
1712
+ await new Promise((r) => setTimeout(r, 300));
1713
+ return { content: [{
1714
+ type: "text",
1715
+ text: `${action} at (${ix}, ${iy})${isLongPress ? ` for ${duration}ms` : ""} on iOS simulator ${udid}.`
1716
+ }] };
1717
+ } else {
1718
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
1719
+ const serial = await resolveSerial(deviceId);
1720
+ const scale = appSession.getPixelRatio(void 0, "android") ?? await getAndroidScale(serial);
1721
+ const topInsetDp = appSession.getTopInsetDp(deviceId, "android");
1722
+ const px = Math.round(x * scale);
1723
+ const py = Math.round((y + topInsetDp) * scale);
1724
+ if (isLongPress) await runAdbCommand([
1725
+ "shell",
1726
+ "input",
1727
+ "swipe",
1728
+ String(px),
1729
+ String(py),
1730
+ String(px),
1731
+ String(py),
1732
+ String(duration)
1733
+ ], serial);
1734
+ else await runAdbCommand([
1735
+ "shell",
1736
+ "input",
1737
+ "tap",
1738
+ String(px),
1739
+ String(py)
1740
+ ], serial);
1741
+ await new Promise((r) => setTimeout(r, 300));
1742
+ return { content: [{
1743
+ type: "text",
1744
+ text: `${action} at dp(${x}, ${y}) → px(${px}, ${py}) [scale=${scale}]${isLongPress ? ` for ${duration}ms` : ""} on Android device ${serial}.`
1745
+ }] };
1746
+ }
1747
+ } catch (err) {
1748
+ return {
1749
+ isError: true,
1750
+ content: [{
1751
+ type: "text",
1752
+ text: `tap failed: ${err instanceof Error ? err.message : String(err)}`
1753
+ }]
1754
+ };
1755
+ }
1756
+ });
1757
+ }
1758
+
1759
+ //#endregion
1760
+ //#region src/tools/swipe.ts
1761
+ /**
1762
+ * MCP 도구: swipe
1763
+ * iOS(idb) / Android(adb) 스와이프 제스처 통합.
1764
+ * duration은 밀리초 단위로 통일 (iOS 내부에서 초로 변환).
1765
+ */
1766
+ const schema$13 = z.object({
1767
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
1768
+ x1: z.number().describe("Start X in points (dp). Auto-converted to pixels on Android."),
1769
+ y1: z.number().describe("Start Y in points (dp). Auto-converted to pixels on Android."),
1770
+ x2: z.number().describe("End X in points (dp). Auto-converted to pixels on Android."),
1771
+ y2: z.number().describe("End Y in points (dp). Auto-converted to pixels on Android."),
1772
+ duration: z.number().optional().default(300).describe("Swipe duration ms. Default 300."),
1773
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find."),
1774
+ iosOrientation: z.number().optional().describe("iOS orientation 1-4. Skips auto-detect.")
1775
+ });
1776
+ function registerSwipe(server, appSession) {
1777
+ server.registerTool("swipe", {
1778
+ description: "Swipe (x1,y1)→(x2,y2) in points. Duration ms (default 300). For scroll/drawer. Use measureView(uid) then swipe; verify with assert_text.",
1779
+ inputSchema: schema$13
1780
+ }, async (args) => {
1781
+ const { platform, x1, y1, x2, y2, duration, deviceId, iosOrientation } = schema$13.parse(args);
1782
+ try {
1783
+ if (platform === "ios") {
1784
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
1785
+ const udid = await resolveUdid(deviceId);
1786
+ const durationSec = duration / 1e3;
1787
+ const info = await getIOSOrientationInfo(appSession, deviceId, platform, udid, iosOrientation);
1788
+ const s1 = transformForIdb(x1, y1, info);
1789
+ const s2 = transformForIdb(x2, y2, info);
1790
+ const ix1 = Math.round(s1.x);
1791
+ const iy1 = Math.round(s1.y);
1792
+ const ix2 = Math.round(s2.x);
1793
+ const iy2 = Math.round(s2.y);
1794
+ const cmd = [
1795
+ "ui",
1796
+ "swipe",
1797
+ String(ix1),
1798
+ String(iy1),
1799
+ String(ix2),
1800
+ String(iy2)
1801
+ ];
1802
+ cmd.push("--duration", String(durationSec));
1803
+ cmd.push("--delta", "10");
1804
+ await runIdbCommand(cmd, udid);
1805
+ return { content: [{
1806
+ type: "text",
1807
+ text: `Swiped from (${x1}, ${y1}) to (${x2}, ${y2}) in ${duration}ms on iOS simulator ${udid}.`
1808
+ }] };
1809
+ } else {
1810
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
1811
+ const serial = await resolveSerial(deviceId);
1812
+ const scale = appSession.getPixelRatio(void 0, "android") ?? await getAndroidScale(serial);
1813
+ const topInsetDp = appSession.getTopInsetDp(deviceId, "android");
1814
+ const px1 = Math.round(x1 * scale);
1815
+ const py1 = Math.round((y1 + topInsetDp) * scale);
1816
+ const px2 = Math.round(x2 * scale);
1817
+ const py2 = Math.round((y2 + topInsetDp) * scale);
1818
+ await runAdbCommand([
1819
+ "shell",
1820
+ "input",
1821
+ "swipe",
1822
+ String(px1),
1823
+ String(py1),
1824
+ String(px2),
1825
+ String(py2),
1826
+ String(duration)
1827
+ ], serial);
1828
+ return { content: [{
1829
+ type: "text",
1830
+ text: `Swiped from dp(${x1}, ${y1}) → px(${px1}, ${py1}) to dp(${x2}, ${y2}) → px(${px2}, ${py2}) [scale=${scale}] in ${duration}ms on Android device ${serial}.`
1831
+ }] };
1832
+ }
1833
+ } catch (err) {
1834
+ return {
1835
+ isError: true,
1836
+ content: [{
1837
+ type: "text",
1838
+ text: `swipe failed: ${err instanceof Error ? err.message : String(err)}`
1839
+ }]
1840
+ };
1841
+ }
1842
+ });
1843
+ }
1844
+
1845
+ //#endregion
1846
+ //#region src/tools/input-text.ts
1847
+ /**
1848
+ * MCP 도구: input_text
1849
+ * iOS(idb) / Android(adb) 현재 포커스된 입력에 텍스트 입력 통합.
1850
+ * ASCII만 지원. 한글/유니코드는 type_text(RN 레벨) 사용 권장.
1851
+ */
1852
+ /** adb shell input text에서 특수문자 이스케이프 */
1853
+ function escapeAdbText(text) {
1854
+ return text.replace(/([\\"'`\s&|;()<>$!#*?{}[\]~^])/g, "\\$1");
1855
+ }
1856
+ const schema$12 = z.object({
1857
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
1858
+ text: z.string().describe("Text to type. ASCII only. Use type_text for Unicode."),
1859
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find.")
1860
+ });
1861
+ function registerInputText(server) {
1862
+ server.registerTool("input_text", {
1863
+ description: "Type into focused input. ASCII only. Use type_text for Korean/Unicode.",
1864
+ inputSchema: schema$12
1865
+ }, async (args) => {
1866
+ const { platform, text, deviceId } = schema$12.parse(args);
1867
+ try {
1868
+ if (platform === "ios") {
1869
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
1870
+ const udid = await resolveUdid(deviceId);
1871
+ await runIdbCommand([
1872
+ "ui",
1873
+ "text",
1874
+ text
1875
+ ], udid);
1876
+ return { content: [{
1877
+ type: "text",
1878
+ text: `Typed "${text}" on iOS simulator ${udid}.`
1879
+ }] };
1880
+ } else {
1881
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
1882
+ const serial = await resolveSerial(deviceId);
1883
+ await runAdbCommand([
1884
+ "shell",
1885
+ "input",
1886
+ "text",
1887
+ escapeAdbText(text)
1888
+ ], serial);
1889
+ return { content: [{
1890
+ type: "text",
1891
+ text: `Typed "${text}" on Android device ${serial}.`
1892
+ }] };
1893
+ }
1894
+ } catch (err) {
1895
+ return {
1896
+ isError: true,
1897
+ content: [{
1898
+ type: "text",
1899
+ text: `input_text failed: ${err instanceof Error ? err.message : String(err)}`
1900
+ }]
1901
+ };
1902
+ }
1903
+ });
1904
+ }
1905
+
1906
+ //#endregion
1907
+ //#region src/tools/input-key.ts
1908
+ /**
1909
+ * MCP 도구: input_key
1910
+ * iOS(idb HID) / Android(adb keyevent) 키코드 전송 통합.
1911
+ */
1912
+ const schema$11 = z.object({
1913
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
1914
+ keycode: z.number().describe("Keycode. iOS: 40=Return, 42=Backspace, 44=Space, 41=Escape. Android: 4=BACK, 66=ENTER, 67=DEL."),
1915
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find.")
1916
+ });
1917
+ function registerInputKey(server) {
1918
+ server.registerTool("input_key", {
1919
+ description: "Send keycode to simulator/device. iOS: HID. Android: adb. Common: Return, Backspace, ENTER, BACK.",
1920
+ inputSchema: schema$11
1921
+ }, async (args) => {
1922
+ const { platform, keycode, deviceId } = schema$11.parse(args);
1923
+ try {
1924
+ if (platform === "ios") {
1925
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
1926
+ const udid = await resolveUdid(deviceId);
1927
+ await runIdbCommand([
1928
+ "ui",
1929
+ "key",
1930
+ String(keycode)
1931
+ ], udid);
1932
+ return { content: [{
1933
+ type: "text",
1934
+ text: `Sent keycode ${keycode} on iOS simulator ${udid}.`
1935
+ }] };
1936
+ } else {
1937
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
1938
+ const serial = await resolveSerial(deviceId);
1939
+ await runAdbCommand([
1940
+ "shell",
1941
+ "input",
1942
+ "keyevent",
1943
+ String(keycode)
1944
+ ], serial);
1945
+ return { content: [{
1946
+ type: "text",
1947
+ text: `Sent keycode ${keycode} on Android device ${serial}.`
1948
+ }] };
1949
+ }
1950
+ } catch (err) {
1951
+ return {
1952
+ isError: true,
1953
+ content: [{
1954
+ type: "text",
1955
+ text: `input_key failed: ${err instanceof Error ? err.message : String(err)}`
1956
+ }]
1957
+ };
1958
+ }
1959
+ });
1960
+ }
1961
+
1962
+ //#endregion
1963
+ //#region src/tools/press-button.ts
1964
+ /**
1965
+ * MCP 도구: press_button
1966
+ * iOS(idb) / Android(adb) 물리 버튼 통합.
1967
+ * Android: HOME, BACK, MENU, APP_SWITCH, POWER, VOLUME_UP, VOLUME_DOWN, ENTER, DEL
1968
+ * iOS: HOME, LOCK, SIDE_BUTTON, SIRI, APPLE_PAY
1969
+ */
1970
+ const ADB_BUTTON_MAP = {
1971
+ HOME: 3,
1972
+ BACK: 4,
1973
+ MENU: 82,
1974
+ APP_SWITCH: 187,
1975
+ POWER: 26,
1976
+ VOLUME_UP: 24,
1977
+ VOLUME_DOWN: 25,
1978
+ ENTER: 66,
1979
+ DEL: 67
1980
+ };
1981
+ const IDB_BUTTONS = new Set([
1982
+ "HOME",
1983
+ "LOCK",
1984
+ "SIDE_BUTTON",
1985
+ "SIRI",
1986
+ "APPLE_PAY"
1987
+ ]);
1988
+ const schema$10 = z.object({
1989
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
1990
+ button: z.string().describe("Android: HOME, BACK, MENU, APP_SWITCH, POWER, VOLUME_UP/DOWN, ENTER, DEL. iOS: HOME, LOCK, SIDE_BUTTON."),
1991
+ duration: z.number().optional().describe("Hold seconds. iOS only."),
1992
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find.")
1993
+ });
1994
+ function registerPressButton(server) {
1995
+ server.registerTool("press_button", {
1996
+ description: "Press physical button. Android: HOME, BACK, MENU, etc. iOS: HOME, LOCK, SIDE_BUTTON. Duration = hold (iOS only).",
1997
+ inputSchema: schema$10
1998
+ }, async (args) => {
1999
+ const { platform, button, duration, deviceId } = schema$10.parse(args);
2000
+ try {
2001
+ if (platform === "ios") {
2002
+ if (!IDB_BUTTONS.has(button)) return { content: [{
2003
+ type: "text",
2004
+ text: `Unknown iOS button "${button}". Available: ${[...IDB_BUTTONS].join(", ")}`
2005
+ }] };
2006
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
2007
+ const udid = await resolveUdid(deviceId);
2008
+ const cmd = [
2009
+ "ui",
2010
+ "button",
2011
+ button
2012
+ ];
2013
+ if (duration != null) cmd.push("--duration", String(duration));
2014
+ await runIdbCommand(cmd, udid);
2015
+ return { content: [{
2016
+ type: "text",
2017
+ text: `Pressed ${button}${duration != null ? ` for ${duration}s` : ""} on iOS simulator ${udid}.`
2018
+ }] };
2019
+ } else {
2020
+ const keycode = ADB_BUTTON_MAP[button];
2021
+ if (keycode == null) return { content: [{
2022
+ type: "text",
2023
+ text: `Unknown Android button "${button}". Available: ${Object.keys(ADB_BUTTON_MAP).join(", ")}`
2024
+ }] };
2025
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2026
+ const serial = await resolveSerial(deviceId);
2027
+ await runAdbCommand([
2028
+ "shell",
2029
+ "input",
2030
+ "keyevent",
2031
+ String(keycode)
2032
+ ], serial);
2033
+ return { content: [{
2034
+ type: "text",
2035
+ text: `Pressed ${button} (keycode ${keycode}) on Android device ${serial}.`
2036
+ }] };
2037
+ }
2038
+ } catch (err) {
2039
+ return {
2040
+ isError: true,
2041
+ content: [{
2042
+ type: "text",
2043
+ text: `press_button failed: ${err instanceof Error ? err.message : String(err)}`
2044
+ }]
2045
+ };
2046
+ }
2047
+ });
2048
+ }
2049
+
2050
+ //#endregion
2051
+ //#region src/tools/describe-ui.ts
2052
+ /**
2053
+ * MCP 도구: describe_ui
2054
+ * iOS(idb describe-all/describe-point) / Android(uiautomator dump) UI 트리 통합.
2055
+ */
2056
+ const xmlParser = new XMLParser({
2057
+ ignoreAttributes: false,
2058
+ attributeNamePrefix: "",
2059
+ isArray: (name) => name === "node"
2060
+ });
2061
+ function stripEmpty(node) {
2062
+ const out = {};
2063
+ for (const [k, v] of Object.entries(node)) {
2064
+ if (k === "node") continue;
2065
+ if (v === "" || v === "false") continue;
2066
+ out[k] = v;
2067
+ }
2068
+ if (node.node && Array.isArray(node.node)) out.children = node.node.map(stripEmpty);
2069
+ return out;
2070
+ }
2071
+ function xmlToJson(xml) {
2072
+ const parsed = xmlParser.parse(xml);
2073
+ const hierarchy = parsed.hierarchy;
2074
+ if (!hierarchy) return JSON.stringify(parsed, null, 2);
2075
+ const result = {};
2076
+ for (const [k, v] of Object.entries(hierarchy)) {
2077
+ if (k === "node") continue;
2078
+ if (v !== "" && v !== "false") result[k] = v;
2079
+ }
2080
+ if (hierarchy.node) result.children = hierarchy.node.map(stripEmpty);
2081
+ return JSON.stringify(result, null, 2);
2082
+ }
2083
+ const schema$9 = z.object({
2084
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
2085
+ mode: z.enum(["all", "point"]).optional().default("all").describe("iOS: \"all\" or \"point\" at (x,y). Android: ignored."),
2086
+ x: z.number().optional().describe("X in points. iOS, required for mode=point."),
2087
+ y: z.number().optional().describe("Y in points. iOS, required for mode=point."),
2088
+ nested: z.boolean().optional().default(false).describe("iOS: hierarchical tree. Android: ignored."),
2089
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find.")
2090
+ });
2091
+ function registerDescribeUi(server) {
2092
+ server.registerTool("describe_ui", {
2093
+ description: "Query native UI/accessibility tree. Large payload. Prefer query_selector for RN elements.",
2094
+ inputSchema: schema$9
2095
+ }, async (args) => {
2096
+ const { platform, mode, x, y, nested, deviceId } = schema$9.parse(args);
2097
+ try {
2098
+ if (platform === "ios") {
2099
+ if (mode === "point" && (x == null || y == null)) return { content: [{
2100
+ type: "text",
2101
+ text: "x and y coordinates are required when mode is \"point\"."
2102
+ }] };
2103
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
2104
+ const udid = await resolveUdid(deviceId);
2105
+ const cmd = [];
2106
+ if (mode === "point") {
2107
+ const ix = Math.round(x);
2108
+ const iy = Math.round(y);
2109
+ cmd.push("ui", "describe-point", String(ix), String(iy));
2110
+ } else cmd.push("ui", "describe-all");
2111
+ if (nested) cmd.push("--nested");
2112
+ cmd.push("--json");
2113
+ return { content: [{
2114
+ type: "text",
2115
+ text: await runIdbCommand(cmd, udid, { timeoutMs: 15e3 })
2116
+ }] };
2117
+ } else {
2118
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2119
+ const serial = await resolveSerial(deviceId);
2120
+ const remotePath = "/sdcard/ui_dump.xml";
2121
+ await runAdbCommand([
2122
+ "shell",
2123
+ "uiautomator",
2124
+ "dump",
2125
+ remotePath
2126
+ ], serial, { timeoutMs: 15e3 });
2127
+ const xml = await runAdbCommand([
2128
+ "exec-out",
2129
+ "cat",
2130
+ remotePath
2131
+ ], serial, { timeoutMs: 1e4 });
2132
+ runAdbCommand([
2133
+ "shell",
2134
+ "rm",
2135
+ "-f",
2136
+ remotePath
2137
+ ], serial).catch(() => {});
2138
+ let output;
2139
+ try {
2140
+ output = xmlToJson(xml);
2141
+ } catch {
2142
+ output = xml;
2143
+ }
2144
+ return { content: [{
2145
+ type: "text",
2146
+ text: output
2147
+ }] };
2148
+ }
2149
+ } catch (err) {
2150
+ return {
2151
+ isError: true,
2152
+ content: [{
2153
+ type: "text",
2154
+ text: `describe_ui failed: ${err instanceof Error ? err.message : String(err)}`
2155
+ }]
2156
+ };
2157
+ }
2158
+ });
2159
+ }
2160
+
2161
+ //#endregion
2162
+ //#region src/tools/file-push.ts
2163
+ /**
2164
+ * MCP 도구: file_push
2165
+ * iOS(idb file push) / Android(adb push) 파일 전송 통합.
2166
+ * iOS는 bundleId 필수 (앱 컨테이너 모델).
2167
+ */
2168
+ const schema$8 = z.object({
2169
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
2170
+ localPath: z.string().describe("Absolute path to the local file to push."),
2171
+ remotePath: z.string().describe("Destination. iOS: in app container. Android: e.g. /sdcard/Download/file.txt."),
2172
+ bundleId: z.string().optional().describe("Bundle ID. iOS only, required."),
2173
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find.")
2174
+ });
2175
+ function registerFilePush(server) {
2176
+ server.registerTool("file_push", {
2177
+ description: "Push local file to simulator/device. iOS: idb + bundleId. Android: adb path.",
2178
+ inputSchema: schema$8
2179
+ }, async (args) => {
2180
+ const { platform, localPath, remotePath, bundleId, deviceId } = schema$8.parse(args);
2181
+ try {
2182
+ if (platform === "ios") {
2183
+ if (!bundleId) return { content: [{
2184
+ type: "text",
2185
+ text: "bundleId is required for iOS file_push. Specify the target app bundle ID (e.g. com.example.myapp)."
2186
+ }] };
2187
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
2188
+ const udid = await resolveUdid(deviceId);
2189
+ await runIdbCommand([
2190
+ "file",
2191
+ "push",
2192
+ localPath,
2193
+ remotePath,
2194
+ bundleId
2195
+ ], udid, { timeoutMs: 3e4 });
2196
+ return { content: [{
2197
+ type: "text",
2198
+ text: `Pushed ${localPath} → ${remotePath} (bundle: ${bundleId}) on iOS simulator ${udid}.`
2199
+ }] };
2200
+ } else {
2201
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2202
+ const serial = await resolveSerial(deviceId);
2203
+ return { content: [{
2204
+ type: "text",
2205
+ text: `Pushed ${localPath} → ${remotePath} on Android device ${serial}.\n${await runAdbCommand([
2206
+ "push",
2207
+ localPath,
2208
+ remotePath
2209
+ ], serial, { timeoutMs: 3e4 })}`
2210
+ }] };
2211
+ }
2212
+ } catch (err) {
2213
+ return {
2214
+ isError: true,
2215
+ content: [{
2216
+ type: "text",
2217
+ text: `file_push failed: ${err instanceof Error ? err.message : String(err)}`
2218
+ }]
2219
+ };
2220
+ }
2221
+ });
2222
+ }
2223
+
2224
+ //#endregion
2225
+ //#region src/tools/add-media.ts
2226
+ /**
2227
+ * MCP 도구: add_media
2228
+ * iOS(idb add-media) / Android(adb push + media scanner) 미디어 추가 통합.
2229
+ */
2230
+ const schema$7 = z.object({
2231
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
2232
+ filePaths: z.array(z.string()).min(1).describe("Absolute paths to images/videos for gallery."),
2233
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find.")
2234
+ });
2235
+ function registerAddMedia(server) {
2236
+ server.registerTool("add_media", {
2237
+ description: "Add images/videos to simulator photo library or device gallery.",
2238
+ inputSchema: schema$7
2239
+ }, async (args) => {
2240
+ const { platform, filePaths, deviceId } = schema$7.parse(args);
2241
+ try {
2242
+ if (platform === "ios") {
2243
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
2244
+ const udid = await resolveUdid(deviceId);
2245
+ await runIdbCommand(["add-media", ...filePaths], udid, { timeoutMs: 3e4 });
2246
+ return { content: [{
2247
+ type: "text",
2248
+ text: `Added ${filePaths.length} media file(s) to iOS simulator ${udid} photo library.`
2249
+ }] };
2250
+ } else {
2251
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2252
+ const serial = await resolveSerial(deviceId);
2253
+ for (const filePath of filePaths) {
2254
+ const remotePath = `/sdcard/Download/${path$1.basename(filePath)}`;
2255
+ await runAdbCommand([
2256
+ "push",
2257
+ filePath,
2258
+ remotePath
2259
+ ], serial, { timeoutMs: 3e4 });
2260
+ await runAdbCommand([
2261
+ "shell",
2262
+ "am",
2263
+ "broadcast",
2264
+ "-a",
2265
+ "android.intent.action.MEDIA_SCANNER_SCAN_FILE",
2266
+ "-d",
2267
+ `file://${remotePath}`
2268
+ ], serial);
2269
+ }
2270
+ return { content: [{
2271
+ type: "text",
2272
+ text: `Added ${filePaths.length} media file(s) to Android device ${serial} gallery.`
2273
+ }] };
2274
+ }
2275
+ } catch (err) {
2276
+ return {
2277
+ isError: true,
2278
+ content: [{
2279
+ type: "text",
2280
+ text: `add_media failed: ${err instanceof Error ? err.message : String(err)}`
2281
+ }]
2282
+ };
2283
+ }
2284
+ });
2285
+ }
2286
+
2287
+ //#endregion
2288
+ //#region src/tools/list-devices.ts
2289
+ /**
2290
+ * MCP 도구: list_devices
2291
+ * iOS(idb list-targets) / Android(adb devices) 디바이스 목록 조회 통합.
2292
+ */
2293
+ const schema$6 = z.object({ platform: z.enum(["ios", "android"]).describe("ios: idb simulators. android: adb devices.") });
2294
+ function registerListDevices(server) {
2295
+ server.registerTool("list_devices", {
2296
+ description: "List connected simulators/devices. Returns deviceId, state, model. Use for other tools.",
2297
+ inputSchema: schema$6
2298
+ }, async (args) => {
2299
+ const { platform } = schema$6.parse(args);
2300
+ try {
2301
+ if (platform === "ios") {
2302
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
2303
+ const targets = await listIdbTargets();
2304
+ const booted = targets.filter((t) => t.state === "Booted");
2305
+ return { content: [{
2306
+ type: "text",
2307
+ text: [
2308
+ `Found ${targets.length} target(s), ${booted.length} booted.`,
2309
+ "",
2310
+ ...targets.map((t) => `${t.state === "Booted" ? "● " : "○ "}${t.name} | ${t.udid} | ${t.state} | ${t.type} | ${t.os_version}`)
2311
+ ].join("\n")
2312
+ }, {
2313
+ type: "text",
2314
+ text: JSON.stringify(targets, null, 2)
2315
+ }] };
2316
+ } else {
2317
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2318
+ const devices = await listAdbDevices();
2319
+ const online = devices.filter((d) => d.state === "device");
2320
+ const withEmulatorFlag = await Promise.all(devices.map(async (d) => ({
2321
+ ...d,
2322
+ isEmulator: d.state === "device" ? await isAndroidEmulator(d.serial) : void 0
2323
+ })));
2324
+ return { content: [{
2325
+ type: "text",
2326
+ text: [
2327
+ `Found ${devices.length} device(s), ${online.length} online.`,
2328
+ "(emulator = AVD, physical = real device. set_location works only on emulator.)",
2329
+ "",
2330
+ ...withEmulatorFlag.map((d) => `${d.state === "device" ? "● " : "○ "}${d.serial} | ${d.state}${d.isEmulator != null ? ` | ${d.isEmulator ? "emulator" : "physical"}` : ""}${d.model ? ` | ${d.model}` : ""}${d.product ? ` | ${d.product}` : ""}`)
2331
+ ].join("\n")
2332
+ }, {
2333
+ type: "text",
2334
+ text: JSON.stringify(withEmulatorFlag, null, 2)
2335
+ }] };
2336
+ }
2337
+ } catch (err) {
2338
+ return {
2339
+ isError: true,
2340
+ content: [{
2341
+ type: "text",
2342
+ text: `list_devices failed: ${err instanceof Error ? err.message : String(err)}`
2343
+ }]
2344
+ };
2345
+ }
2346
+ });
2347
+ }
2348
+
2349
+ //#endregion
2350
+ //#region src/tools/open-deeplink.ts
2351
+ /**
2352
+ * MCP tool: open_deeplink
2353
+ * Open a deep link / URL on iOS simulator (xcrun simctl) or Android device (adb).
2354
+ */
2355
+ const schema$5 = z.object({
2356
+ url: z.string().describe("Deep link URL (e.g. myapp://product/123)."),
2357
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
2358
+ deviceId: z.string().optional().describe("Device ID. Auto if single. list_devices to find.")
2359
+ });
2360
+ function registerOpenDeeplink(server) {
2361
+ server.registerTool("open_deeplink", {
2362
+ description: "Open deep link on simulator/device. Navigate to screens via URL scheme.",
2363
+ inputSchema: schema$5
2364
+ }, async (args) => {
2365
+ const { url, platform, deviceId } = schema$5.parse(args);
2366
+ try {
2367
+ if (platform === "ios") {
2368
+ const target = deviceId ?? "booted";
2369
+ await runCommand("xcrun", [
2370
+ "simctl",
2371
+ "openurl",
2372
+ target,
2373
+ url
2374
+ ]);
2375
+ return { content: [{
2376
+ type: "text",
2377
+ text: `Opened deep link "${url}" on iOS simulator ${target}.`
2378
+ }] };
2379
+ } else {
2380
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2381
+ const serial = await resolveSerial(deviceId);
2382
+ await runAdbCommand([
2383
+ "shell",
2384
+ "am",
2385
+ "start",
2386
+ "-a",
2387
+ "android.intent.action.VIEW",
2388
+ "-d",
2389
+ url
2390
+ ], serial);
2391
+ return { content: [{
2392
+ type: "text",
2393
+ text: `Opened deep link "${url}" on Android device ${serial}.`
2394
+ }] };
2395
+ }
2396
+ } catch (err) {
2397
+ return {
2398
+ isError: true,
2399
+ content: [{
2400
+ type: "text",
2401
+ text: `open_deeplink failed: ${err instanceof Error ? err.message : String(err)}`
2402
+ }]
2403
+ };
2404
+ }
2405
+ });
2406
+ }
2407
+
2408
+ //#endregion
2409
+ //#region src/tools/clear-state.ts
2410
+ /**
2411
+ * MCP 도구: clear_state
2412
+ * 앱 데이터/권한 초기화. Android: pm clear. iOS: simctl privacy reset (권한만).
2413
+ *
2414
+ * 플랫폼별 차이:
2415
+ * - Android: adb shell pm clear <package> — 앱 데이터 전부 삭제(AsyncStorage, SharedPreferences 등).
2416
+ * - iOS: xcrun simctl privacy <udid> reset all <bundleId> — 권한/프라이버시 리셋만.
2417
+ * 앱 샌드박스(문서/캐시)는 삭제되지 않음. 완전 초기화는 앱 삭제 후 재설치 필요.
2418
+ */
2419
+ const schema$4 = z.object({
2420
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
2421
+ appId: z.string().describe("Bundle ID (iOS) or package name (Android)."),
2422
+ deviceId: z.string().optional().describe("Optional. iOS: UDID. Android: serial. Auto if single device.")
2423
+ });
2424
+ function registerClearState(server) {
2425
+ server.registerTool("clear_state", {
2426
+ description: "Clear app data (Android: pm clear) or reset permissions (iOS: privacy only). Use before tests. iOS full reset needs uninstall+reinstall.",
2427
+ inputSchema: schema$4
2428
+ }, async (args) => {
2429
+ const { platform, appId, deviceId } = schema$4.parse(args);
2430
+ try {
2431
+ if (platform === "ios") {
2432
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
2433
+ const udid = await resolveUdid(deviceId);
2434
+ await runCommand("xcrun", [
2435
+ "simctl",
2436
+ "privacy",
2437
+ udid,
2438
+ "reset",
2439
+ "all",
2440
+ appId
2441
+ ], { timeoutMs: 15e3 });
2442
+ return { content: [{
2443
+ type: "text",
2444
+ text: `Reset permissions for ${appId} on iOS simulator ${udid}. (Note: iOS only resets privacy permissions; app sandbox/data is not cleared. For full reset, uninstall and reinstall the app.)`
2445
+ }] };
2446
+ } else {
2447
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2448
+ const serial = await resolveSerial(deviceId);
2449
+ await runAdbCommand([
2450
+ "shell",
2451
+ "pm",
2452
+ "clear",
2453
+ appId
2454
+ ], serial, { timeoutMs: 15e3 });
2455
+ return { content: [{
2456
+ type: "text",
2457
+ text: `Cleared all app data for ${appId} on Android device ${serial}.`
2458
+ }] };
2459
+ }
2460
+ } catch (err) {
2461
+ return {
2462
+ isError: true,
2463
+ content: [{
2464
+ type: "text",
2465
+ text: `clear_state failed: ${err instanceof Error ? err.message : String(err)}`
2466
+ }]
2467
+ };
2468
+ }
2469
+ });
2470
+ }
2471
+
2472
+ //#endregion
2473
+ //#region src/tools/set-location.ts
2474
+ /**
2475
+ * MCP 도구: set_location
2476
+ * 시뮬레이터/에뮬레이터에 위도·경도 설정. Android 실기기 미지원.
2477
+ *
2478
+ * 플랫폼별 차이:
2479
+ * - iOS: idb set-location <lat> <lon> — 시뮬레이터 모두 지원.
2480
+ * - Android: adb emu geo fix <lon> <lat> — 에뮬레이터 전용. 실기기에서는 동작하지 않음.
2481
+ */
2482
+ const schema$3 = z.object({
2483
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
2484
+ latitude: z.number().min(-90).max(90).describe("Latitude (-90–90)."),
2485
+ longitude: z.number().min(-180).max(180).describe("Longitude (-180–180)."),
2486
+ deviceId: z.string().optional().describe("Optional. iOS: UDID. Android: serial. Auto if single device.")
2487
+ });
2488
+ function registerSetLocation(server) {
2489
+ server.registerTool("set_location", {
2490
+ description: "Set GPS on iOS simulator or Android emulator. Android: emulator only.",
2491
+ inputSchema: schema$3
2492
+ }, async (args) => {
2493
+ const { platform, latitude, longitude, deviceId } = schema$3.parse(args);
2494
+ try {
2495
+ if (platform === "ios") {
2496
+ if (!await checkIdbAvailable()) return idbNotInstalledError();
2497
+ const udid = await resolveUdid(deviceId);
2498
+ await runIdbCommand([
2499
+ "set-location",
2500
+ String(latitude),
2501
+ String(longitude)
2502
+ ], udid, { timeoutMs: 5e3 });
2503
+ return { content: [{
2504
+ type: "text",
2505
+ text: `Set location to ${latitude}, ${longitude} on iOS simulator ${udid}.`
2506
+ }] };
2507
+ } else {
2508
+ if (!await checkAdbAvailable()) return adbNotInstalledError();
2509
+ const serial = await resolveSerial(deviceId);
2510
+ if (!await isAndroidEmulator(serial)) return {
2511
+ isError: true,
2512
+ content: [{
2513
+ type: "text",
2514
+ text: `set_location on Android is supported only on emulator. Device ${serial} appears to be a physical device (ro.kernel.qemu is not 1). Use an AVD (emulator) for location simulation.`
2515
+ }]
2516
+ };
2517
+ await runAdbCommand([
2518
+ "emu",
2519
+ "geo",
2520
+ "fix",
2521
+ String(longitude),
2522
+ String(latitude)
2523
+ ], serial, { timeoutMs: 5e3 });
2524
+ return { content: [{
2525
+ type: "text",
2526
+ text: `Set location to ${latitude}, ${longitude} on Android emulator ${serial}. (Note: set_location on Android works only on emulator, not on physical devices.)`
2527
+ }] };
2528
+ }
2529
+ } catch (err) {
2530
+ return {
2531
+ isError: true,
2532
+ content: [{
2533
+ type: "text",
2534
+ text: `set_location failed: ${err instanceof Error ? err.message : String(err)}.${platform === "android" ? " set_location on Android is supported only on emulator; use an AVD, not a physical device." : ""}`
2535
+ }]
2536
+ };
2537
+ }
2538
+ });
2539
+ }
2540
+
2541
+ //#endregion
2542
+ //#region src/tools/scroll-until-visible.ts
2543
+ /**
2544
+ * MCP 도구: scroll_until_visible
2545
+ * 요소가 보일 때까지 자동 스크롤. querySelector + swipe 반복.
2546
+ * FlatList 가상화로 미렌더링된 아이템도 스크롤하면 나타남.
2547
+ */
2548
+ const schema$2 = z.object({
2549
+ selector: z.string().describe("Selector of element to find."),
2550
+ direction: z.enum([
2551
+ "up",
2552
+ "down",
2553
+ "left",
2554
+ "right"
2555
+ ]).optional().default("down").describe("Scroll direction. Default \"down\"."),
2556
+ maxScrolls: z.number().optional().default(10).describe("Max scroll attempts. Default 10."),
2557
+ scrollableSelector: z.string().optional().describe("Selector for scroll container. Omit to use center."),
2558
+ platform: z.enum(["ios", "android"]).describe("Target platform."),
2559
+ deviceId: z.string().optional().describe("Device ID. Auto if single."),
2560
+ iosOrientation: z.number().optional().describe("iOS orientation 1-4. Skips auto-detect.")
2561
+ });
2562
+ function sleep(ms) {
2563
+ return new Promise((resolve) => setTimeout(resolve, ms));
2564
+ }
2565
+ function registerScrollUntilVisible(server, appSession) {
2566
+ server.registerTool("scroll_until_visible", {
2567
+ description: "Scroll until selector is visible. querySelector + swipe loop. Returns pass, scrollCount, element. For long lists.",
2568
+ inputSchema: schema$2
2569
+ }, async (args) => {
2570
+ const { selector, direction, maxScrolls, scrollableSelector, platform, deviceId, iosOrientation } = schema$2.parse(args);
2571
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
2572
+ type: "text",
2573
+ text: "No React Native app connected."
2574
+ }] };
2575
+ const queryCode = buildQuerySelectorEvalCode(selector);
2576
+ /** 요소의 중심이 뷰포트(화면) 안에 있는지. measure가 없으면 true(기존 동작 유지). */
2577
+ function isInViewport(measure, screenWidth, screenHeight) {
2578
+ if (!measure) return true;
2579
+ const { pageX, pageY, width, height } = measure;
2580
+ const centerX = pageX + width / 2;
2581
+ const centerY = pageY + height / 2;
2582
+ return centerX >= 0 && centerX <= screenWidth && centerY >= 0 && centerY <= screenHeight;
2583
+ }
2584
+ async function getScreenBounds() {
2585
+ const screenRes = await appSession.sendRequest({
2586
+ method: "eval",
2587
+ params: { code: `(function(){ var M = typeof __REACT_NATIVE_MCP__ !== 'undefined' ? __REACT_NATIVE_MCP__ : null; return M && M.getScreenInfo ? M.getScreenInfo() : null; })();` }
2588
+ }, 1e4, deviceId, platform);
2589
+ if (screenRes.result?.window) {
2590
+ const w = screenRes.result.window;
2591
+ return {
2592
+ width: w.width,
2593
+ height: w.height
2594
+ };
2595
+ }
2596
+ return {
2597
+ width: 360,
2598
+ height: 800
2599
+ };
2600
+ }
2601
+ async function getScrollArea() {
2602
+ if (scrollableSelector) {
2603
+ const scrollCode = buildQuerySelectorEvalCode(scrollableSelector);
2604
+ const res = await appSession.sendRequest({
2605
+ method: "eval",
2606
+ params: { code: scrollCode }
2607
+ }, 1e4, deviceId, platform);
2608
+ if (res.result?.measure) {
2609
+ const m = res.result.measure;
2610
+ return {
2611
+ centerX: m.pageX + m.width / 2,
2612
+ centerY: m.pageY + m.height / 2,
2613
+ width: m.width,
2614
+ height: m.height
2615
+ };
2616
+ }
2617
+ }
2618
+ const screenRes = await appSession.sendRequest({
2619
+ method: "eval",
2620
+ params: { code: `(function(){ var M = typeof __REACT_NATIVE_MCP__ !== 'undefined' ? __REACT_NATIVE_MCP__ : null; return M && M.getScreenInfo ? M.getScreenInfo() : null; })();` }
2621
+ }, 1e4, deviceId, platform);
2622
+ if (screenRes.result?.window) {
2623
+ const w = screenRes.result.window;
2624
+ return {
2625
+ centerX: w.width / 2,
2626
+ centerY: w.height / 2,
2627
+ width: w.width,
2628
+ height: w.height
2629
+ };
2630
+ }
2631
+ return {
2632
+ centerX: 180,
2633
+ centerY: 400,
2634
+ width: 360,
2635
+ height: 800
2636
+ };
2637
+ }
2638
+ function calcSwipeCoords(area, dir) {
2639
+ const swipeDistance = dir === "left" || dir === "right" ? area.width * .6 : area.height * .4;
2640
+ switch (dir) {
2641
+ case "down": return {
2642
+ x1: area.centerX,
2643
+ y1: area.centerY + swipeDistance / 2,
2644
+ x2: area.centerX,
2645
+ y2: area.centerY - swipeDistance / 2
2646
+ };
2647
+ case "up": return {
2648
+ x1: area.centerX,
2649
+ y1: area.centerY - swipeDistance / 2,
2650
+ x2: area.centerX,
2651
+ y2: area.centerY + swipeDistance / 2
2652
+ };
2653
+ case "right": return {
2654
+ x1: area.centerX + swipeDistance / 2,
2655
+ y1: area.centerY,
2656
+ x2: area.centerX - swipeDistance / 2,
2657
+ y2: area.centerY
2658
+ };
2659
+ case "left": return {
2660
+ x1: area.centerX - swipeDistance / 2,
2661
+ y1: area.centerY,
2662
+ x2: area.centerX + swipeDistance / 2,
2663
+ y2: area.centerY
2664
+ };
2665
+ default: return {
2666
+ x1: area.centerX,
2667
+ y1: area.centerY + swipeDistance / 2,
2668
+ x2: area.centerX,
2669
+ y2: area.centerY - swipeDistance / 2
2670
+ };
2671
+ }
2672
+ }
2673
+ async function performSwipe(coords, orientationInfo) {
2674
+ const duration = 500;
2675
+ if (platform === "ios") {
2676
+ if (!await checkIdbAvailable()) throw new Error("idb not available");
2677
+ const udid = await resolveUdid(deviceId);
2678
+ const s1 = transformForIdb(coords.x1, coords.y1, orientationInfo);
2679
+ const s2 = transformForIdb(coords.x2, coords.y2, orientationInfo);
2680
+ await runIdbCommand([
2681
+ "ui",
2682
+ "swipe",
2683
+ String(Math.round(s1.x)),
2684
+ String(Math.round(s1.y)),
2685
+ String(Math.round(s2.x)),
2686
+ String(Math.round(s2.y)),
2687
+ "--duration",
2688
+ String(duration / 1e3),
2689
+ "--delta",
2690
+ "10"
2691
+ ], udid);
2692
+ } else {
2693
+ if (!await checkAdbAvailable()) throw new Error("adb not available");
2694
+ const serial = await resolveSerial(deviceId);
2695
+ const scale = appSession.getPixelRatio(void 0, "android") ?? await getAndroidScale(serial);
2696
+ const topInsetDp = appSession.getTopInsetDp(deviceId, "android");
2697
+ const px1 = Math.round(coords.x1 * scale);
2698
+ const py1 = Math.round((coords.y1 + topInsetDp) * scale);
2699
+ const px2 = Math.round(coords.x2 * scale);
2700
+ const py2 = Math.round((coords.y2 + topInsetDp) * scale);
2701
+ await runAdbCommand([
2702
+ "shell",
2703
+ "input",
2704
+ "swipe",
2705
+ String(px1),
2706
+ String(py1),
2707
+ String(px2),
2708
+ String(py2),
2709
+ String(duration)
2710
+ ], serial);
2711
+ }
2712
+ }
2713
+ try {
2714
+ const bounds = await getScreenBounds();
2715
+ const initialRes = await appSession.sendRequest({
2716
+ method: "eval",
2717
+ params: { code: queryCode }
2718
+ }, 1e4, deviceId, platform);
2719
+ if (initialRes.result != null) {
2720
+ const el = initialRes.result;
2721
+ if (isInViewport(el.measure, bounds.width, bounds.height)) return { content: [{
2722
+ type: "text",
2723
+ text: JSON.stringify({
2724
+ pass: true,
2725
+ scrollCount: 0,
2726
+ element: initialRes.result,
2727
+ message: `Element found without scrolling.`
2728
+ })
2729
+ }] };
2730
+ }
2731
+ const swipeCoords = calcSwipeCoords(await getScrollArea(), direction);
2732
+ const orientationInfo = await getIOSOrientationInfo(appSession, deviceId, platform, platform === "ios" ? await resolveUdid(deviceId) : "", iosOrientation);
2733
+ for (let i = 0; i < maxScrolls; i++) {
2734
+ await performSwipe(swipeCoords, orientationInfo);
2735
+ await sleep(500);
2736
+ const res = await appSession.sendRequest({
2737
+ method: "eval",
2738
+ params: { code: queryCode }
2739
+ }, 1e4, deviceId, platform);
2740
+ if (res.result != null) {
2741
+ const el = res.result;
2742
+ if (isInViewport(el.measure, bounds.width, bounds.height)) return { content: [{
2743
+ type: "text",
2744
+ text: JSON.stringify({
2745
+ pass: true,
2746
+ scrollCount: i + 1,
2747
+ element: res.result,
2748
+ message: `Element found after ${i + 1} scroll(s).`
2749
+ })
2750
+ }] };
2751
+ }
2752
+ }
2753
+ return { content: [{
2754
+ type: "text",
2755
+ text: JSON.stringify({
2756
+ pass: false,
2757
+ scrollCount: maxScrolls,
2758
+ message: `Element matching "${selector}" not found after ${maxScrolls} scrolls.`
2759
+ })
2760
+ }] };
2761
+ } catch (err) {
2762
+ return {
2763
+ isError: true,
2764
+ content: [{
2765
+ type: "text",
2766
+ text: `scroll_until_visible failed: ${err instanceof Error ? err.message : String(err)}`
2767
+ }]
2768
+ };
2769
+ }
2770
+ });
2771
+ }
2772
+
2773
+ //#endregion
2774
+ //#region src/tools/inspect-state.ts
2775
+ /**
2776
+ * MCP 도구: inspect_state
2777
+ * 셀렉터로 찾은 컴포넌트의 React state Hook 목록을 반환.
2778
+ * runtime.js의 inspectState(selector)를 eval로 호출.
2779
+ */
2780
+ const schema$1 = z.object({
2781
+ selector: z.string().describe("Selector for component (e.g. CartScreen, #cart-view)."),
2782
+ deviceId: deviceParam,
2783
+ platform: platformParam
2784
+ });
2785
+ function registerInspectState(server, appSession) {
2786
+ server.registerTool("inspect_state", {
2787
+ description: "Inspect React state hooks of component by selector. Returns hooks with index and value. Works with useState, Zustand, etc.",
2788
+ inputSchema: schema$1
2789
+ }, async (args) => {
2790
+ const parsed = schema$1.safeParse(args ?? {});
2791
+ if (!parsed.success) return {
2792
+ isError: true,
2793
+ content: [{
2794
+ type: "text",
2795
+ text: `Invalid arguments: ${parsed.error.message}`
2796
+ }]
2797
+ };
2798
+ const { selector, deviceId, platform } = parsed.data;
2799
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
2800
+ type: "text",
2801
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
2802
+ }] };
2803
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.inspectState ? __REACT_NATIVE_MCP__.inspectState(${JSON.stringify(selector)}) : null; })();`;
2804
+ try {
2805
+ const res = await appSession.sendRequest({
2806
+ method: "eval",
2807
+ params: { code }
2808
+ }, 1e4, deviceId, platform);
2809
+ if (res.error != null) return { content: [{
2810
+ type: "text",
2811
+ text: `Error: ${res.error}`
2812
+ }] };
2813
+ if (!res.result) return { content: [{
2814
+ type: "text",
2815
+ text: `No component found for selector: ${selector}`
2816
+ }] };
2817
+ const result = res.result;
2818
+ const lines = [`Component: ${result.component}`, `State hooks: ${result.hooks.length}`];
2819
+ for (const hook of result.hooks) lines.push(` [${hook.index}] ${hook.type}: ${JSON.stringify(hook.value, null, 2)}`);
2820
+ return { content: [{
2821
+ type: "text",
2822
+ text: lines.join("\n")
2823
+ }] };
2824
+ } catch (err) {
2825
+ return {
2826
+ isError: true,
2827
+ content: [{
2828
+ type: "text",
2829
+ text: `inspect_state failed: ${err instanceof Error ? err.message : String(err)}`
2830
+ }]
2831
+ };
2832
+ }
2833
+ });
2834
+ }
2835
+
2836
+ //#endregion
2837
+ //#region src/tools/get-state-changes.ts
2838
+ /**
2839
+ * MCP 도구: get_state_changes, clear_state_changes
2840
+ * onCommitFiberRoot에서 수집된 상태 변경 이력을 조회/초기화.
2841
+ * runtime.js의 getStateChanges / clearStateChanges를 eval로 호출.
2842
+ */
2843
+ const listSchema$1 = z.object({
2844
+ component: z.string().optional().describe("Filter by component name. Omit for all."),
2845
+ since: z.number().optional().describe("Only changes after timestamp (ms)."),
2846
+ limit: z.number().optional().describe("Max changes to return. Default 100."),
2847
+ deviceId: deviceParam,
2848
+ platform: platformParam
2849
+ });
2850
+ const clearSchema$2 = z.object({
2851
+ deviceId: deviceParam,
2852
+ platform: platformParam
2853
+ });
2854
+ function registerGetStateChanges(server, appSession) {
2855
+ const s = server;
2856
+ s.registerTool("get_state_changes", {
2857
+ description: "List captured state changes (timestamp, component, hook, prev/next). Filter by component, since, limit. Buffer up to 300.",
2858
+ inputSchema: listSchema$1
2859
+ }, async (args) => {
2860
+ const parsed = listSchema$1.safeParse(args ?? {});
2861
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
2862
+ const platform = parsed.success ? parsed.data.platform : void 0;
2863
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
2864
+ type: "text",
2865
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
2866
+ }] };
2867
+ const options = {};
2868
+ if (parsed.success) {
2869
+ if (parsed.data.component != null) options.component = parsed.data.component;
2870
+ if (parsed.data.since != null) options.since = parsed.data.since;
2871
+ if (parsed.data.limit != null) options.limit = parsed.data.limit;
2872
+ }
2873
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.getStateChanges ? __REACT_NATIVE_MCP__.getStateChanges(${JSON.stringify(options)}) : []; })();`;
2874
+ try {
2875
+ const res = await appSession.sendRequest({
2876
+ method: "eval",
2877
+ params: { code }
2878
+ }, 1e4, deviceId, platform);
2879
+ if (res.error != null) return { content: [{
2880
+ type: "text",
2881
+ text: `Error: ${res.error}`
2882
+ }] };
2883
+ const list = Array.isArray(res.result) ? res.result : [];
2884
+ if (list.length === 0) return { content: [{
2885
+ type: "text",
2886
+ text: "No state changes recorded."
2887
+ }] };
2888
+ const lines = list.map((entry) => {
2889
+ const ts = entry.timestamp ? new Date(entry.timestamp).toISOString() : "?";
2890
+ return `[${entry.id}] ${ts} ${entry.component}[hook ${entry.hookIndex}]: ${JSON.stringify(entry.prev)} → ${JSON.stringify(entry.next)}`;
2891
+ });
2892
+ return { content: [{
2893
+ type: "text",
2894
+ text: `${list.length} state change(s):\n${lines.join("\n")}`
2895
+ }] };
2896
+ } catch (err) {
2897
+ return {
2898
+ isError: true,
2899
+ content: [{
2900
+ type: "text",
2901
+ text: `get_state_changes failed: ${err instanceof Error ? err.message : String(err)}`
2902
+ }]
2903
+ };
2904
+ }
2905
+ });
2906
+ s.registerTool("clear_state_changes", {
2907
+ description: "Clear all captured state change entries from the buffer.",
2908
+ inputSchema: clearSchema$2
2909
+ }, async (args) => {
2910
+ const parsed = clearSchema$2.safeParse(args ?? {});
2911
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
2912
+ const platform = parsed.success ? parsed.data.platform : void 0;
2913
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
2914
+ type: "text",
2915
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
2916
+ }] };
2917
+ const code = `(function(){ if (typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.clearStateChanges) { __REACT_NATIVE_MCP__.clearStateChanges(); return true; } return false; })();`;
2918
+ try {
2919
+ const res = await appSession.sendRequest({
2920
+ method: "eval",
2921
+ params: { code }
2922
+ }, 1e4, deviceId, platform);
2923
+ if (res.error != null) return { content: [{
2924
+ type: "text",
2925
+ text: `Error: ${res.error}`
2926
+ }] };
2927
+ return { content: [{
2928
+ type: "text",
2929
+ text: "State changes cleared."
2930
+ }] };
2931
+ } catch (err) {
2932
+ return {
2933
+ isError: true,
2934
+ content: [{
2935
+ type: "text",
2936
+ text: `clear_state_changes failed: ${err instanceof Error ? err.message : String(err)}`
2937
+ }]
2938
+ };
2939
+ }
2940
+ });
2941
+ }
2942
+
2943
+ //#endregion
2944
+ //#region src/tools/network-mock.ts
2945
+ /**
2946
+ * MCP 도구: set_network_mock, list_network_mocks, clear_network_mocks, remove_network_mock
2947
+ * runtime.js의 addNetworkMock / listNetworkMocks / clearNetworkMocks / removeNetworkMock를 eval로 호출.
2948
+ */
2949
+ const setSchema = z.object({
2950
+ urlPattern: z.string().describe("URL pattern (substring or regex)."),
2951
+ isRegex: z.boolean().optional().describe("urlPattern is regex."),
2952
+ method: z.string().optional().describe("HTTP method. Omit to match all."),
2953
+ status: z.number().optional().describe("Mock status. Default 200."),
2954
+ statusText: z.string().optional().describe("Mock status text."),
2955
+ headers: z.record(z.string()).optional().describe("Mock headers."),
2956
+ body: z.string().optional().describe("Mock body."),
2957
+ delay: z.number().optional().describe("Delay ms before mock."),
2958
+ deviceId: deviceParam,
2959
+ platform: platformParam
2960
+ });
2961
+ const listSchema = z.object({
2962
+ deviceId: deviceParam,
2963
+ platform: platformParam
2964
+ });
2965
+ const clearSchema$1 = z.object({
2966
+ deviceId: deviceParam,
2967
+ platform: platformParam
2968
+ });
2969
+ const removeSchema = z.object({
2970
+ id: z.number().describe("Mock rule ID to remove."),
2971
+ deviceId: deviceParam,
2972
+ platform: platformParam
2973
+ });
2974
+ function notConnectedResponse() {
2975
+ return { content: [{
2976
+ type: "text",
2977
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
2978
+ }] };
2979
+ }
2980
+ function registerNetworkMock(server, appSession) {
2981
+ const s = server;
2982
+ s.registerTool("set_network_mock", {
2983
+ description: "Add network mock. Matching XHR/fetch return mock without hitting network.",
2984
+ inputSchema: setSchema
2985
+ }, async (args) => {
2986
+ const parsed = setSchema.safeParse(args ?? {});
2987
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
2988
+ const platform = parsed.success ? parsed.data.platform : void 0;
2989
+ if (!appSession.isConnected(deviceId, platform)) return notConnectedResponse();
2990
+ const opts = {};
2991
+ if (parsed.success) {
2992
+ opts.urlPattern = parsed.data.urlPattern;
2993
+ if (parsed.data.isRegex != null) opts.isRegex = parsed.data.isRegex;
2994
+ if (parsed.data.method != null) opts.method = parsed.data.method;
2995
+ if (parsed.data.status != null) opts.status = parsed.data.status;
2996
+ if (parsed.data.statusText != null) opts.statusText = parsed.data.statusText;
2997
+ if (parsed.data.headers != null) opts.headers = parsed.data.headers;
2998
+ if (parsed.data.body != null) opts.body = parsed.data.body;
2999
+ if (parsed.data.delay != null) opts.delay = parsed.data.delay;
3000
+ }
3001
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.addNetworkMock ? __REACT_NATIVE_MCP__.addNetworkMock(${JSON.stringify(opts)}) : null; })();`;
3002
+ try {
3003
+ const res = await appSession.sendRequest({
3004
+ method: "eval",
3005
+ params: { code }
3006
+ }, 1e4, deviceId, platform);
3007
+ if (res.error != null) return { content: [{
3008
+ type: "text",
3009
+ text: `Error: ${res.error}`
3010
+ }] };
3011
+ return { content: [{
3012
+ type: "text",
3013
+ text: JSON.stringify(res.result, null, 2)
3014
+ }] };
3015
+ } catch (err) {
3016
+ return {
3017
+ isError: true,
3018
+ content: [{
3019
+ type: "text",
3020
+ text: `set_network_mock failed: ${err instanceof Error ? err.message : String(err)}`
3021
+ }]
3022
+ };
3023
+ }
3024
+ });
3025
+ s.registerTool("list_network_mocks", {
3026
+ description: "List all active network mock rules.",
3027
+ inputSchema: listSchema
3028
+ }, async (args) => {
3029
+ const parsed = listSchema.safeParse(args ?? {});
3030
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
3031
+ const platform = parsed.success ? parsed.data.platform : void 0;
3032
+ if (!appSession.isConnected(deviceId, platform)) return notConnectedResponse();
3033
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.listNetworkMocks ? __REACT_NATIVE_MCP__.listNetworkMocks() : []; })();`;
3034
+ try {
3035
+ const res = await appSession.sendRequest({
3036
+ method: "eval",
3037
+ params: { code }
3038
+ }, 1e4, deviceId, platform);
3039
+ if (res.error != null) return { content: [{
3040
+ type: "text",
3041
+ text: `Error: ${res.error}`
3042
+ }] };
3043
+ const list = Array.isArray(res.result) ? res.result : [];
3044
+ if (list.length === 0) return { content: [{
3045
+ type: "text",
3046
+ text: "No network mock rules."
3047
+ }] };
3048
+ const lines = list.map((r) => `[${r.id}] ${r.method || "*"} ${r.isRegex ? "/" + r.urlPattern + "/" : r.urlPattern} → ${r.status} (hits: ${r.hitCount})`);
3049
+ return { content: [{
3050
+ type: "text",
3051
+ text: `${list.length} mock rule(s):\n${lines.join("\n")}`
3052
+ }] };
3053
+ } catch (err) {
3054
+ return {
3055
+ isError: true,
3056
+ content: [{
3057
+ type: "text",
3058
+ text: `list_network_mocks failed: ${err instanceof Error ? err.message : String(err)}`
3059
+ }]
3060
+ };
3061
+ }
3062
+ });
3063
+ s.registerTool("clear_network_mocks", {
3064
+ description: "Clear all network mock rules.",
3065
+ inputSchema: clearSchema$1
3066
+ }, async (args) => {
3067
+ const parsed = clearSchema$1.safeParse(args ?? {});
3068
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
3069
+ const platform = parsed.success ? parsed.data.platform : void 0;
3070
+ if (!appSession.isConnected(deviceId, platform)) return notConnectedResponse();
3071
+ const code = `(function(){ if (typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.clearNetworkMocks) { __REACT_NATIVE_MCP__.clearNetworkMocks(); return true; } return false; })();`;
3072
+ try {
3073
+ const res = await appSession.sendRequest({
3074
+ method: "eval",
3075
+ params: { code }
3076
+ }, 1e4, deviceId, platform);
3077
+ if (res.error != null) return { content: [{
3078
+ type: "text",
3079
+ text: `Error: ${res.error}`
3080
+ }] };
3081
+ return { content: [{
3082
+ type: "text",
3083
+ text: "Network mock rules cleared."
3084
+ }] };
3085
+ } catch (err) {
3086
+ return {
3087
+ isError: true,
3088
+ content: [{
3089
+ type: "text",
3090
+ text: `clear_network_mocks failed: ${err instanceof Error ? err.message : String(err)}`
3091
+ }]
3092
+ };
3093
+ }
3094
+ });
3095
+ s.registerTool("remove_network_mock", {
3096
+ description: "Remove a specific network mock rule by ID.",
3097
+ inputSchema: removeSchema
3098
+ }, async (args) => {
3099
+ const parsed = removeSchema.safeParse(args ?? {});
3100
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
3101
+ const platform = parsed.success ? parsed.data.platform : void 0;
3102
+ if (!appSession.isConnected(deviceId, platform)) return notConnectedResponse();
3103
+ const id = parsed.success ? parsed.data.id : 0;
3104
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.removeNetworkMock ? __REACT_NATIVE_MCP__.removeNetworkMock(${JSON.stringify(id)}) : false; })();`;
3105
+ try {
3106
+ const res = await appSession.sendRequest({
3107
+ method: "eval",
3108
+ params: { code }
3109
+ }, 1e4, deviceId, platform);
3110
+ if (res.error != null) return { content: [{
3111
+ type: "text",
3112
+ text: `Error: ${res.error}`
3113
+ }] };
3114
+ return { content: [{
3115
+ type: "text",
3116
+ text: res.result === true ? `Mock rule ${id} removed.` : `Mock rule ${id} not found.`
3117
+ }] };
3118
+ } catch (err) {
3119
+ return {
3120
+ isError: true,
3121
+ content: [{
3122
+ type: "text",
3123
+ text: `remove_network_mock failed: ${err instanceof Error ? err.message : String(err)}`
3124
+ }]
3125
+ };
3126
+ }
3127
+ });
3128
+ }
3129
+
3130
+ //#endregion
3131
+ //#region src/tools/image-compare.ts
3132
+ /**
3133
+ * 두 PNG 이미지를 픽셀 단위로 비교한다.
3134
+ * @param baselinePng 기준 이미지 PNG Buffer
3135
+ * @param currentPng 현재 이미지 PNG Buffer
3136
+ * @param threshold pixelmatch threshold (0~1). 기본 0.1 (pixelmatch 기본값)
3137
+ * @returns CompareResult
3138
+ */
3139
+ async function compareImages(baselinePng, currentPng, threshold = .1) {
3140
+ const sharp = (await import("sharp")).default;
3141
+ const pixelmatch = (await import("pixelmatch")).default;
3142
+ const [baseMeta, curMeta] = await Promise.all([sharp(baselinePng).metadata(), sharp(currentPng).metadata()]);
3143
+ const bw = baseMeta.width;
3144
+ const bh = baseMeta.height;
3145
+ const cw = curMeta.width;
3146
+ const ch = curMeta.height;
3147
+ if (bw !== cw || bh !== ch) return {
3148
+ pass: false,
3149
+ diffRatio: 1,
3150
+ diffPixels: bw * bh,
3151
+ totalPixels: bw * bh,
3152
+ dimensions: {
3153
+ width: bw,
3154
+ height: bh
3155
+ },
3156
+ message: `Size mismatch: baseline ${bw}x${bh} vs current ${cw}x${ch}`
3157
+ };
3158
+ const [baseRaw, curRaw] = await Promise.all([sharp(baselinePng).ensureAlpha().raw().toBuffer(), sharp(currentPng).ensureAlpha().raw().toBuffer()]);
3159
+ const totalPixels = bw * bh;
3160
+ const diffRaw = Buffer.alloc(bw * bh * 4);
3161
+ const diffPixels = pixelmatch(baseRaw, curRaw, diffRaw, bw, bh, { threshold });
3162
+ const diffRatio = diffPixels / totalPixels;
3163
+ const diffBuffer = await sharp(diffRaw, { raw: {
3164
+ width: bw,
3165
+ height: bh,
3166
+ channels: 4
3167
+ } }).png().toBuffer();
3168
+ const pass = diffPixels === 0;
3169
+ return {
3170
+ pass,
3171
+ diffRatio,
3172
+ diffPixels,
3173
+ totalPixels,
3174
+ dimensions: {
3175
+ width: bw,
3176
+ height: bh
3177
+ },
3178
+ diffBuffer,
3179
+ message: pass ? `Images match (${bw}x${bh})` : `${diffPixels} pixels differ (${(diffRatio * 100).toFixed(2)}%) in ${bw}x${bh} image`
3180
+ };
3181
+ }
3182
+ /**
3183
+ * PNG 이미지에서 지정 영역을 크롭한다.
3184
+ * @param png 원본 PNG Buffer
3185
+ * @param rect 크롭 영역 (pixel 단위)
3186
+ * @returns 크롭된 PNG Buffer
3187
+ */
3188
+ async function cropElement(png, rect) {
3189
+ const sharp = (await import("sharp")).default;
3190
+ return sharp(png).extract(rect).png().toBuffer();
3191
+ }
3192
+ /**
3193
+ * PNG 이미지에서 스크린 스케일을 계산한다.
3194
+ * 런타임 pixelRatio를 우선 사용하고, 없으면 플랫폼별 fallback.
3195
+ */
3196
+ async function getScreenScale(png, platform, runtimePixelRatio) {
3197
+ if (runtimePixelRatio != null) return runtimePixelRatio;
3198
+ if (platform === "android") {
3199
+ const { getAndroidScale } = await import("./adb-utils-R7jCnMjU.js");
3200
+ return getAndroidScale();
3201
+ }
3202
+ const sharp = (await import("sharp")).default;
3203
+ const density = (await sharp(png).metadata()).density || 72;
3204
+ return Math.round(density / 72) || 1;
3205
+ }
3206
+
3207
+ //#endregion
3208
+ //#region src/tools/visual-compare.ts
3209
+ /**
3210
+ * MCP 도구: visual_compare
3211
+ * 스크린샷을 캡처하여 베이스라인 PNG 파일과 픽셀 단위 비교.
3212
+ * selector 지정 시 해당 요소만 크롭하여 비교 (컴포넌트 단위 비주얼 리그레션).
3213
+ */
3214
+ const schema = z.object({
3215
+ platform: z.enum(["android", "ios"]).describe("Target platform."),
3216
+ baseline: z.string().describe("Absolute path to baseline PNG file."),
3217
+ selector: z.string().optional().describe("CSS-like selector to crop a specific element. If omitted, compares full screen."),
3218
+ threshold: z.number().min(0).max(1).optional().describe("pixelmatch threshold (0~1). Default 0.1."),
3219
+ updateBaseline: z.boolean().optional().describe("If true, save current screenshot as new baseline and skip comparison."),
3220
+ saveDiff: z.string().optional().describe("Path to save diff image PNG."),
3221
+ saveCurrent: z.string().optional().describe("Path to save current screenshot PNG."),
3222
+ deviceId: z.string().optional()
3223
+ });
3224
+ function registerVisualCompare(server, appSession) {
3225
+ server.registerTool("visual_compare", {
3226
+ description: "Compare current screenshot against a baseline PNG. Supports element-level cropping via selector. Use updateBaseline=true to save new baselines.",
3227
+ inputSchema: schema
3228
+ }, async (args) => {
3229
+ const { platform, baseline, selector, threshold = .1, updateBaseline, saveDiff, saveCurrent, deviceId } = schema.parse(args);
3230
+ try {
3231
+ const rawPng = platform === "android" ? await captureAndroid() : await captureIos();
3232
+ if (!isValidPng(rawPng)) throw new Error("Capture produced invalid PNG.");
3233
+ let currentPng;
3234
+ if (selector) {
3235
+ const measure = await queryMeasure(appSession, selector, deviceId, platform);
3236
+ if (!measure) throw new Error(`Element not found for selector: ${selector}`);
3237
+ const scale = await getScreenScale(rawPng, platform, appSession.getPixelRatio(deviceId, platform));
3238
+ const topInsetDp = appSession.getTopInsetDp(deviceId, platform);
3239
+ currentPng = await cropElement(rawPng, {
3240
+ left: Math.round(measure.pageX * scale),
3241
+ top: Math.round((measure.pageY + topInsetDp) * scale),
3242
+ width: Math.round(measure.width * scale),
3243
+ height: Math.round(measure.height * scale)
3244
+ });
3245
+ } else currentPng = rawPng;
3246
+ if (saveCurrent) {
3247
+ await mkdir(dirname(saveCurrent), { recursive: true });
3248
+ await writeFile(saveCurrent, currentPng);
3249
+ }
3250
+ if (updateBaseline) {
3251
+ await mkdir(dirname(baseline), { recursive: true });
3252
+ await writeFile(baseline, currentPng);
3253
+ return { content: [{
3254
+ type: "text",
3255
+ text: JSON.stringify({
3256
+ updated: true,
3257
+ baseline,
3258
+ message: `Baseline updated: ${baseline}`
3259
+ })
3260
+ }] };
3261
+ }
3262
+ let baselinePng;
3263
+ try {
3264
+ baselinePng = await readFile(baseline);
3265
+ } catch {
3266
+ throw new Error(`Baseline not found: ${baseline}. Run with updateBaseline=true to create it.`);
3267
+ }
3268
+ const result = await compareImages(baselinePng, currentPng, threshold);
3269
+ if (saveDiff && result.diffBuffer) {
3270
+ await mkdir(dirname(saveDiff), { recursive: true });
3271
+ await writeFile(saveDiff, result.diffBuffer);
3272
+ }
3273
+ return {
3274
+ content: [{
3275
+ type: "text",
3276
+ text: JSON.stringify({
3277
+ pass: result.pass,
3278
+ diffRatio: result.diffRatio,
3279
+ diffPixels: result.diffPixels,
3280
+ totalPixels: result.totalPixels,
3281
+ dimensions: result.dimensions,
3282
+ threshold,
3283
+ baseline,
3284
+ selector: selector ?? null,
3285
+ saveDiff: saveDiff ?? null,
3286
+ message: result.message
3287
+ })
3288
+ }],
3289
+ isError: !result.pass ? true : void 0
3290
+ };
3291
+ } catch (err) {
3292
+ return {
3293
+ isError: true,
3294
+ content: [{
3295
+ type: "text",
3296
+ text: `visual_compare failed: ${err instanceof Error ? err.message : String(err)}`
3297
+ }]
3298
+ };
3299
+ }
3300
+ });
3301
+ }
3302
+ /** querySelector로 요소의 measure 좌표를 가져온다. */
3303
+ async function queryMeasure(appSession, selector, deviceId, platform) {
3304
+ const code = buildQuerySelectorEvalCode(selector);
3305
+ const res = await appSession.sendRequest({
3306
+ method: "eval",
3307
+ params: { code }
3308
+ }, 1e4, deviceId, platform);
3309
+ if (res.error != null || res.result == null) return null;
3310
+ return res.result.measure ?? null;
3311
+ }
3312
+
3313
+ //#endregion
3314
+ //#region src/tools/render-tracking.ts
3315
+ /**
3316
+ * MCP 도구: start_render_profile, get_render_report, clear_render_profile
3317
+ * React 컴포넌트 리렌더 프로파일링 — 불필요 리렌더 탐지, 핫 컴포넌트 리포트.
3318
+ */
3319
+ const startSchema = z.object({
3320
+ components: z.array(z.string()).optional().describe("Whitelist: track only these components. Overrides ignore."),
3321
+ ignore: z.array(z.string()).optional().describe("Blacklist: skip these components (added to default ignore list). Ignored when components is set."),
3322
+ deviceId: deviceParam,
3323
+ platform: platformParam
3324
+ });
3325
+ const reportSchema = z.object({
3326
+ deviceId: deviceParam,
3327
+ platform: platformParam
3328
+ });
3329
+ const clearSchema = z.object({
3330
+ deviceId: deviceParam,
3331
+ platform: platformParam
3332
+ });
3333
+ function registerRenderTracking(server, appSession) {
3334
+ const s = server;
3335
+ s.registerTool("start_render_profile", {
3336
+ description: "Start render profiling. Tracks component mounts, re-renders, and unnecessary renders caused by parent re-renders. Optional `components` filter.",
3337
+ inputSchema: startSchema
3338
+ }, async (args) => {
3339
+ const parsed = startSchema.safeParse(args ?? {});
3340
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
3341
+ const platform = parsed.success ? parsed.data.platform : void 0;
3342
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
3343
+ type: "text",
3344
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
3345
+ }] };
3346
+ const options = {};
3347
+ if (parsed.success && parsed.data.components) options.components = parsed.data.components;
3348
+ if (parsed.success && parsed.data.ignore) options.ignore = parsed.data.ignore;
3349
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.startRenderProfile ? __REACT_NATIVE_MCP__.startRenderProfile(${JSON.stringify(options)}) : null; })();`;
3350
+ try {
3351
+ const res = await appSession.sendRequest({
3352
+ method: "eval",
3353
+ params: { code }
3354
+ }, 1e4, deviceId, platform);
3355
+ if (res.error != null) return { content: [{
3356
+ type: "text",
3357
+ text: `Error: ${res.error}`
3358
+ }] };
3359
+ let filterMsg = "";
3360
+ if (parsed.success && parsed.data.components) filterMsg = ` Whitelist: ${parsed.data.components.join(", ")}`;
3361
+ else if (parsed.success && parsed.data.ignore) filterMsg = ` Ignoring: ${parsed.data.ignore.join(", ")} (+ defaults)`;
3362
+ else filterMsg = " Tracking all components (default ignore applied).";
3363
+ return { content: [{
3364
+ type: "text",
3365
+ text: `Render profiling started.${filterMsg}`
3366
+ }] };
3367
+ } catch (err) {
3368
+ return {
3369
+ isError: true,
3370
+ content: [{
3371
+ type: "text",
3372
+ text: `start_render_profile failed: ${err instanceof Error ? err.message : String(err)}`
3373
+ }]
3374
+ };
3375
+ }
3376
+ });
3377
+ s.registerTool("get_render_report", {
3378
+ description: "Get render profiling report. Shows hot components sorted by render count, unnecessary renders (preventable with React.memo), and recent render details with trigger analysis.",
3379
+ inputSchema: reportSchema
3380
+ }, async (args) => {
3381
+ const parsed = reportSchema.safeParse(args ?? {});
3382
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
3383
+ const platform = parsed.success ? parsed.data.platform : void 0;
3384
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
3385
+ type: "text",
3386
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
3387
+ }] };
3388
+ const code = `(function(){ return typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.getRenderReport ? __REACT_NATIVE_MCP__.getRenderReport() : null; })();`;
3389
+ try {
3390
+ const res = await appSession.sendRequest({
3391
+ method: "eval",
3392
+ params: { code }
3393
+ }, 1e4, deviceId, platform);
3394
+ if (res.error != null) return { content: [{
3395
+ type: "text",
3396
+ text: `Error: ${res.error}`
3397
+ }] };
3398
+ if (!res.result) return { content: [{
3399
+ type: "text",
3400
+ text: "No render profiling data. Call start_render_profile first."
3401
+ }] };
3402
+ return { content: [{
3403
+ type: "text",
3404
+ text: JSON.stringify(res.result, null, 2)
3405
+ }] };
3406
+ } catch (err) {
3407
+ return {
3408
+ isError: true,
3409
+ content: [{
3410
+ type: "text",
3411
+ text: `get_render_report failed: ${err instanceof Error ? err.message : String(err)}`
3412
+ }]
3413
+ };
3414
+ }
3415
+ });
3416
+ s.registerTool("clear_render_profile", {
3417
+ description: "Stop render profiling and clear all collected data.",
3418
+ inputSchema: clearSchema
3419
+ }, async (args) => {
3420
+ const parsed = clearSchema.safeParse(args ?? {});
3421
+ const deviceId = parsed.success ? parsed.data.deviceId : void 0;
3422
+ const platform = parsed.success ? parsed.data.platform : void 0;
3423
+ if (!appSession.isConnected(deviceId, platform)) return { content: [{
3424
+ type: "text",
3425
+ text: "No React Native app connected. Start the app with Metro and ensure the MCP runtime is loaded."
3426
+ }] };
3427
+ const code = `(function(){ if (typeof __REACT_NATIVE_MCP__ !== 'undefined' && __REACT_NATIVE_MCP__.clearRenderProfile) { __REACT_NATIVE_MCP__.clearRenderProfile(); return true; } return false; })();`;
3428
+ try {
3429
+ const res = await appSession.sendRequest({
3430
+ method: "eval",
3431
+ params: { code }
3432
+ }, 1e4, deviceId, platform);
3433
+ if (res.error != null) return { content: [{
3434
+ type: "text",
3435
+ text: `Error: ${res.error}`
3436
+ }] };
3437
+ return { content: [{
3438
+ type: "text",
3439
+ text: "Render profiling stopped and data cleared."
3440
+ }] };
3441
+ } catch (err) {
3442
+ return {
3443
+ isError: true,
3444
+ content: [{
3445
+ type: "text",
3446
+ text: `clear_render_profile failed: ${err instanceof Error ? err.message : String(err)}`
3447
+ }]
3448
+ };
3449
+ }
3450
+ });
3451
+ }
3452
+
3453
+ //#endregion
3454
+ //#region src/tools/index.ts
3455
+ function registerAllTools(server, appSession) {
3456
+ registerEvaluateScript(server, appSession);
3457
+ registerTakeSnapshot(server, appSession);
3458
+ registerTakeScreenshot(server, appSession);
3459
+ registerAccessibilityAudit(server, appSession);
3460
+ registerWebviewEvaluateScript(server, appSession);
3461
+ registerTypeText(server, appSession);
3462
+ registerQuerySelector(server, appSession);
3463
+ registerAssert(server, appSession);
3464
+ registerGetDebuggerStatus(server, appSession);
3465
+ registerListConsoleMessages(server, appSession);
3466
+ registerListNetworkRequests(server, appSession);
3467
+ registerTap(server, appSession);
3468
+ registerSwipe(server, appSession);
3469
+ registerInputText(server);
3470
+ registerInputKey(server);
3471
+ registerPressButton(server);
3472
+ registerDescribeUi(server);
3473
+ registerFilePush(server);
3474
+ registerAddMedia(server);
3475
+ registerListDevices(server);
3476
+ registerSwitchKeyboard(server);
3477
+ registerOpenDeeplink(server);
3478
+ registerClearState(server);
3479
+ registerSetLocation(server);
3480
+ registerScrollUntilVisible(server, appSession);
3481
+ registerInspectState(server, appSession);
3482
+ registerGetStateChanges(server, appSession);
3483
+ registerNetworkMock(server, appSession);
3484
+ registerVisualCompare(server, appSession);
3485
+ registerRenderTracking(server, appSession);
3486
+ }
3487
+
3488
+ //#endregion
3489
+ //#region src/guides/query-selector.ts
3490
+ const querySelectorGuide = {
3491
+ name: "query-selector-syntax",
3492
+ description: "query_selector / query_selector_all selector syntax reference. Type, testID, text, attribute, hierarchy, capability selectors and workflow examples.",
3493
+ content: `# query_selector / query_selector_all
3494
+
3495
+ Selector syntax for finding elements in the React Native Fiber tree. Similar to CSS \`querySelector\` but for the **React Fiber tree**, not DOM.
3496
+
3497
+ ## Basic syntax
3498
+
3499
+ ### Type selector
3500
+
3501
+ Match by component type name:
3502
+
3503
+ \`\`\`
3504
+ View
3505
+ Text
3506
+ ScrollView
3507
+ Pressable
3508
+ FlatList
3509
+ TextInput
3510
+ \`\`\`
3511
+
3512
+ ### testID selector
3513
+
3514
+ \`#\` for testID:
3515
+
3516
+ \`\`\`
3517
+ #login-btn
3518
+ #email-input
3519
+ #product-list
3520
+ \`\`\`
3521
+
3522
+ Combine with type:
3523
+
3524
+ \`\`\`
3525
+ Pressable#login-btn
3526
+ TextInput#email-input
3527
+ \`\`\`
3528
+
3529
+ ### Text selector
3530
+
3531
+ \`:text("...")\` — **substring match** on subtree text:
3532
+
3533
+ \`\`\`
3534
+ :text("Login")
3535
+ :text("Submit")
3536
+ Text:text("Welcome")
3537
+ Pressable:text("OK")
3538
+ \`\`\`
3539
+
3540
+ > Text is concatenated from all children. E.g. \`<View><Text>Hello</Text><Text>World</Text></View>\` matches \`:text("Hello World")\`.
3541
+
3542
+ ### Attribute selector
3543
+
3544
+ \`[attr="value"]\` — match by props:
3545
+
3546
+ \`\`\`
3547
+ [accessibilityLabel="Close"]
3548
+ [placeholder="Email"]
3549
+ View[accessibilityRole="button"]
3550
+ \`\`\`
3551
+
3552
+ ### displayName selector
3553
+
3554
+ \`:display-name("...")\` — match by \`fiber.type.displayName\` (independent of type name):
3555
+
3556
+ \`\`\`
3557
+ :display-name("Animated.View") # Reanimated: type is AnimatedComponent, displayName matches Animated.View
3558
+ View:display-name("CustomBox")
3559
+ \`\`\`
3560
+
3561
+ ### Index selectors
3562
+
3563
+ \`:first-of-type\` — first match (same as \`:nth-of-type(1)\`). \`:last-of-type\` — last match:
3564
+
3565
+ \`\`\`
3566
+ Pressable:first-of-type # first Pressable
3567
+ Pressable:last-of-type # last Pressable
3568
+ View:text("Bottom sheet"):last-of-type # last View containing "Bottom sheet"
3569
+ \`\`\`
3570
+
3571
+ \`:nth-of-type(N)\` — Nth match (1-based):
3572
+
3573
+ \`\`\`
3574
+ Text:nth-of-type(1) # first Text (= :first-of-type)
3575
+ Pressable:nth-of-type(3) # third Pressable
3576
+ :text("Item"):nth-of-type(2) # second element containing "Item"
3577
+ \`\`\`
3578
+
3579
+ ### Capability selectors
3580
+
3581
+ \`:has-press\` — element has \`onPress\` handler:
3582
+
3583
+ \`\`\`
3584
+ :has-press # all pressable elements
3585
+ View:has-press # View with onPress
3586
+ :has-press:text("Delete") # pressable with "Delete" text
3587
+ \`\`\`
3588
+
3589
+ \`:has-scroll\` — element has \`scrollTo\` or \`scrollToOffset\`:
3590
+
3591
+ \`\`\`
3592
+ :has-scroll # all scrollable elements
3593
+ ScrollView:has-scroll # scrollable ScrollView
3594
+ \`\`\`
3595
+
3596
+ ## Hierarchy selectors
3597
+
3598
+ ### Direct child (\`>\`)
3599
+
3600
+ \`\`\`
3601
+ View > Text # Text that is direct child of View
3602
+ ScrollView > View > Text # 3-level hierarchy
3603
+ \`\`\`
3604
+
3605
+ ### Descendant (space)
3606
+
3607
+ \`\`\`
3608
+ View Text # Text anywhere under View
3609
+ ScrollView Pressable # any Pressable under ScrollView
3610
+ \`\`\`
3611
+
3612
+ ### Combined example
3613
+
3614
+ \`\`\`
3615
+ View > ScrollView:has-scroll > Pressable:text("Add")
3616
+ \`\`\`
3617
+
3618
+ ## OR (comma)
3619
+
3620
+ Combine multiple selectors with comma:
3621
+
3622
+ \`\`\`
3623
+ ScrollView, FlatList # ScrollView or FlatList
3624
+ Pressable, TouchableOpacity # either type
3625
+ #btn-a, #btn-b # either testID
3626
+ \`\`\`
3627
+
3628
+ ## Compound examples
3629
+
3630
+ \`\`\`
3631
+ # Find login button
3632
+ Pressable:text("Login")
3633
+
3634
+ # TextInput by testID
3635
+ TextInput#email-input
3636
+
3637
+ # Close button by accessibility label
3638
+ [accessibilityLabel="Close"]:has-press
3639
+
3640
+ # Third pressable inside ScrollView
3641
+ ScrollView :has-press:nth-of-type(3)
3642
+
3643
+ # FlatList or ScrollView
3644
+ FlatList, ScrollView
3645
+
3646
+ # All Text inside a specific View
3647
+ View#header > Text
3648
+ \`\`\`
3649
+
3650
+ ## Return value
3651
+
3652
+ ### query_selector (single)
3653
+
3654
+ \`\`\`json
3655
+ {
3656
+ "uid": "login-btn",
3657
+ "type": "Pressable",
3658
+ "testID": "login-btn",
3659
+ "text": "Login",
3660
+ "accessibilityLabel": null,
3661
+ "hasOnPress": true,
3662
+ "hasOnLongPress": false,
3663
+ "hasScrollTo": false,
3664
+ "measure": {
3665
+ "x": 100, "y": 200,
3666
+ "width": 150, "height": 44,
3667
+ "pageX": 100, "pageY": 200
3668
+ }
3669
+ }
3670
+ \`\`\`
3671
+
3672
+ - \`uid\` — testID when present, otherwise path string (e.g. \`"0.1.2"\`)
3673
+ - \`measure\` — element coordinates and size (points). pageX/pageY are absolute from top-left of screen.
3674
+ - If \`measure\` is null, use \`evaluate_script(measureView(uid))\` to get it (rare).
3675
+
3676
+ ### query_selector_all (multiple)
3677
+
3678
+ \`\`\`json
3679
+ [
3680
+ { "uid": "item-0", "type": "Pressable", "text": "Item 1", "hasOnPress": true, "measure": { ... } },
3681
+ { "uid": "item-1", "type": "Pressable", "text": "Item 2", "hasOnPress": true, "measure": { ... } }
3682
+ ]
3683
+ \`\`\`
3684
+
3685
+ ## Workflow
3686
+
3687
+ ### Find element → native tap (2 steps)
3688
+
3689
+ \`\`\`
3690
+ 1. query_selector('Pressable:text("Login")') → get uid + measure
3691
+ 2. tap(platform, measure.pageX + measure.width/2, measure.pageY + measure.height/2) → tap center
3692
+ \`\`\`
3693
+
3694
+ > measure is included in the result, so a separate measureView call is not needed.
3695
+
3696
+ ### Assert text
3697
+
3698
+ \`\`\`
3699
+ assert_text("Welcome") → { pass: true }
3700
+ \`\`\`
3701
+
3702
+ ### List all pressable elements
3703
+
3704
+ \`\`\`
3705
+ query_selector_all(':has-press')
3706
+ \`\`\`
3707
+
3708
+ ### All text nodes
3709
+
3710
+ \`\`\`
3711
+ query_selector_all('Text')
3712
+ \`\`\`
3713
+
3714
+ ## Syntax summary
3715
+
3716
+ | Syntax | Description | Example |
3717
+ |---|---|---|
3718
+ | \`Type\` | Component type | \`View\`, \`Text\`, \`Pressable\` |
3719
+ | \`#id\` | testID | \`#login-btn\` |
3720
+ | \`[attr="val"]\` | Props attribute | \`[accessibilityLabel="Close"]\` |
3721
+ | \`:text("...")\` | Text substring match | \`:text("Login")\` |
3722
+ | \`:display-name("...")\` | fiber.type.displayName match | \`:display-name("Animated.View")\` |
3723
+ | \`:first-of-type\` | First match | \`Pressable:first-of-type\` |
3724
+ | \`:last-of-type\` | Last match | \`View:text("Bottom sheet"):last-of-type\` |
3725
+ | \`:nth-of-type(N)\` | Nth match (1-based) | \`:nth-of-type(1)\` |
3726
+ | \`:has-press\` | Has onPress | \`:has-press\` |
3727
+ | \`:has-scroll\` | Has scrollTo | \`:has-scroll\` |
3728
+ | \`A > B\` | Direct child | \`View > Text\` |
3729
+ | \`A B\` | Descendant | \`View Text\` |
3730
+ | \`A, B\` | OR | \`ScrollView, FlatList\` |
3731
+ `
3732
+ };
3733
+
3734
+ //#endregion
3735
+ //#region src/resources/query-selector.ts
3736
+ function registerQuerySelectorResource(server) {
3737
+ server.registerResource(querySelectorGuide.name, `docs://guides/${querySelectorGuide.name}`, {
3738
+ description: querySelectorGuide.description,
3739
+ mimeType: "text/markdown"
3740
+ }, () => ({ contents: [{
3741
+ uri: `docs://guides/${querySelectorGuide.name}`,
3742
+ text: querySelectorGuide.content,
3743
+ mimeType: "text/markdown"
3744
+ }] }));
3745
+ }
3746
+
3747
+ //#endregion
3748
+ //#region src/resources/index.ts
3749
+ function registerAllResources(server) {
3750
+ registerQuerySelectorResource(server);
3751
+ }
3752
+
3753
+ //#endregion
3754
+ //#region src/index.ts
3755
+ /**
3756
+ * React Native MCP 서버 진입점
3757
+ * Stdio transport (Cursor 연동) + WebSocket 서버 (앱 연동)
3758
+ *
3759
+ * `init` 서브커맨드: 프로젝트 셋업 CLI
3760
+ */
3761
+ const VERSION = "0.1.0";
3762
+ const WS_PORT = 12300;
3763
+ /**
3764
+ * MCP 서버 시작
3765
+ */
3766
+ async function main() {
3767
+ appSession.start(WS_PORT);
3768
+ const server = new McpServer({
3769
+ name: "react-native-mcp-server",
3770
+ version: VERSION
3771
+ });
3772
+ registerAllTools(server, appSession);
3773
+ registerAllResources(server);
3774
+ const transport = new StdioServerTransport();
3775
+ await server.connect(transport);
3776
+ console.error("[react-native-mcp-server] Running on stdio");
3777
+ }
3778
+ if (process.argv[2] === "init") {
3779
+ const { parseArgs } = await import("node:util");
3780
+ const { runInit } = await import("./init-202kCwU5.js");
3781
+ const VALID_CLIENTS = [
3782
+ "cursor",
3783
+ "claude-code",
3784
+ "claude-desktop",
3785
+ "windsurf",
3786
+ "antigravity"
3787
+ ];
3788
+ const { values } = parseArgs({
3789
+ args: process.argv.slice(3),
3790
+ options: {
3791
+ client: { type: "string" },
3792
+ yes: {
3793
+ type: "boolean",
3794
+ short: "y",
3795
+ default: false
3796
+ },
3797
+ help: {
3798
+ type: "boolean",
3799
+ short: "h"
3800
+ }
3801
+ }
3802
+ });
3803
+ if (values.help) console.log(`
3804
+ Usage: react-native-mcp-server init [options]
3805
+
3806
+ Options:
3807
+ --client <name> MCP client: cursor, claude-code, claude-desktop, windsurf, antigravity
3808
+ -y, --yes Skip prompts (non-interactive mode)
3809
+ --help, -h Show this help message
3810
+ `);
3811
+ else {
3812
+ let client;
3813
+ if (values.client) if (!VALID_CLIENTS.includes(values.client)) {
3814
+ console.error(`Invalid client: ${values.client}`);
3815
+ console.error(`Valid clients: ${VALID_CLIENTS.join(", ")}`);
3816
+ process.exitCode = 1;
3817
+ } else client = values.client;
3818
+ if (process.exitCode !== 1) await runInit({
3819
+ client,
3820
+ interactive: !values.yes
3821
+ });
3822
+ }
3823
+ } else main().catch((err) => {
3824
+ console.error(err);
3825
+ process.exit(1);
3826
+ });
3827
+
3828
+ //#endregion
3829
+ export { VERSION };