@ceraph/react-native-mcp 0.2.1 → 0.3.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.
Files changed (124) hide show
  1. package/LICENSE +116 -15
  2. package/README.md +79 -77
  3. package/assets/default.png +0 -0
  4. package/dist/app-lifecycle.d.ts +50 -0
  5. package/dist/app-lifecycle.js +487 -0
  6. package/dist/camera-image-writer.d.ts +43 -0
  7. package/dist/camera-image-writer.js +280 -0
  8. package/dist/camera-registry-sync.d.ts +18 -0
  9. package/dist/camera-registry-sync.js +117 -0
  10. package/dist/cli.d.ts +0 -7
  11. package/dist/cli.js +41 -9
  12. package/dist/device-autonomy.d.ts +30 -0
  13. package/dist/device-autonomy.js +117 -0
  14. package/dist/error-parser.d.ts +6 -26
  15. package/dist/error-parser.js +4 -74
  16. package/dist/expo-manager.d.ts +2 -74
  17. package/dist/expo-manager.js +11 -125
  18. package/dist/index.d.ts +0 -7
  19. package/dist/index.js +1266 -56
  20. package/dist/init/ast-camera.d.ts +29 -0
  21. package/dist/init/ast-camera.js +267 -0
  22. package/dist/init/ast-layout.d.ts +15 -0
  23. package/dist/init/ast-layout.js +167 -0
  24. package/dist/init/claude-hook-constants.d.ts +9 -0
  25. package/dist/init/claude-hook-constants.js +91 -0
  26. package/dist/init/lan-ip.d.ts +11 -0
  27. package/dist/init/lan-ip.js +51 -0
  28. package/dist/init/monorepo.d.ts +13 -0
  29. package/dist/init/monorepo.js +185 -0
  30. package/dist/init/oauth.d.ts +52 -0
  31. package/dist/init/oauth.js +220 -0
  32. package/dist/init/package-manager.d.ts +11 -0
  33. package/dist/init/package-manager.js +60 -0
  34. package/dist/init/prompt.d.ts +12 -0
  35. package/dist/init/prompt.js +68 -0
  36. package/dist/init/shell-profile.d.ts +22 -0
  37. package/dist/init/shell-profile.js +85 -0
  38. package/dist/init/steps.d.ts +135 -0
  39. package/dist/init/steps.js +399 -0
  40. package/dist/init/url-scheme.d.ts +42 -0
  41. package/dist/init/url-scheme.js +187 -0
  42. package/dist/init/walkthrough.d.ts +76 -0
  43. package/dist/init/walkthrough.js +340 -0
  44. package/dist/init.d.ts +7 -7
  45. package/dist/init.js +280 -120
  46. package/dist/iproxy-manager.d.ts +32 -0
  47. package/dist/iproxy-manager.js +216 -0
  48. package/dist/mac-caffeinate.d.ts +10 -0
  49. package/dist/mac-caffeinate.js +56 -0
  50. package/dist/permission-interceptor.d.ts +29 -0
  51. package/dist/permission-interceptor.js +185 -0
  52. package/dist/prebuild-detector.d.ts +0 -30
  53. package/dist/prebuild-detector.js +1 -42
  54. package/dist/preflight.d.ts +34 -0
  55. package/dist/preflight.js +847 -0
  56. package/dist/screen.d.ts +132 -43
  57. package/dist/screen.js +668 -94
  58. package/dist/shim/boot.d.ts +41 -0
  59. package/dist/shim/boot.js +141 -0
  60. package/dist/shim/camera.d.ts +22 -0
  61. package/dist/shim/camera.js +62 -0
  62. package/dist/shim/config.d.ts +6 -0
  63. package/dist/shim/config.js +56 -0
  64. package/dist/shim/deep-link.d.ts +1 -0
  65. package/dist/shim/deep-link.js +25 -0
  66. package/dist/shim/dev-guard.d.ts +1 -0
  67. package/dist/shim/dev-guard.js +3 -0
  68. package/dist/shim/error-handler.d.ts +20 -0
  69. package/dist/shim/error-handler.js +66 -0
  70. package/dist/shim/fetch-interceptor.d.ts +13 -0
  71. package/dist/shim/fetch-interceptor.js +93 -0
  72. package/dist/shim/index.d.ts +6 -0
  73. package/dist/shim/index.js +6 -0
  74. package/dist/shim/keep-awake.d.ts +13 -0
  75. package/dist/shim/keep-awake.js +118 -0
  76. package/dist/shim/reload.d.ts +23 -0
  77. package/dist/shim/reload.js +76 -0
  78. package/dist/shim/signal-capture.d.ts +11 -0
  79. package/dist/shim/signal-capture.js +15 -0
  80. package/dist/shim/signal-transport.d.ts +17 -0
  81. package/dist/shim/signal-transport.js +43 -0
  82. package/dist/signal-listener.d.ts +27 -0
  83. package/dist/signal-listener.js +135 -0
  84. package/dist/simulator-boot.d.ts +52 -0
  85. package/dist/simulator-boot.js +227 -0
  86. package/dist/target.d.ts +48 -0
  87. package/dist/target.js +267 -0
  88. package/dist/uninstall/cli-runner.d.ts +32 -0
  89. package/dist/uninstall/cli-runner.js +223 -0
  90. package/dist/uninstall/footprint.d.ts +40 -0
  91. package/dist/uninstall/footprint.js +288 -0
  92. package/dist/uninstall/mcp-tools.d.ts +14 -0
  93. package/dist/uninstall/mcp-tools.js +175 -0
  94. package/dist/uninstall/revert-auth.d.ts +22 -0
  95. package/dist/uninstall/revert-auth.js +31 -0
  96. package/dist/uninstall/revert-boot.d.ts +24 -0
  97. package/dist/uninstall/revert-boot.js +242 -0
  98. package/dist/uninstall/revert-camera.d.ts +12 -0
  99. package/dist/uninstall/revert-camera.js +199 -0
  100. package/dist/uninstall/revert-ceraph-dir.d.ts +27 -0
  101. package/dist/uninstall/revert-ceraph-dir.js +38 -0
  102. package/dist/uninstall/revert-claude-hooks.d.ts +19 -0
  103. package/dist/uninstall/revert-claude-hooks.js +191 -0
  104. package/dist/uninstall/revert-gitignore.d.ts +17 -0
  105. package/dist/uninstall/revert-gitignore.js +43 -0
  106. package/dist/uninstall/revert-mcp-clients.d.ts +57 -0
  107. package/dist/uninstall/revert-mcp-clients.js +194 -0
  108. package/dist/uninstall/revert-package.d.ts +34 -0
  109. package/dist/uninstall/revert-package.js +98 -0
  110. package/dist/uninstall/revert-scheme.d.ts +36 -0
  111. package/dist/uninstall/revert-scheme.js +139 -0
  112. package/dist/uninstall/revert-signal-host-env.d.ts +31 -0
  113. package/dist/uninstall/revert-signal-host-env.js +61 -0
  114. package/dist/uninstall/walkthrough.d.ts +80 -0
  115. package/dist/uninstall/walkthrough.js +1244 -0
  116. package/dist/utils/atomic-write.d.ts +1 -0
  117. package/dist/utils/atomic-write.js +30 -0
  118. package/dist/wait-for-device.d.ts +68 -0
  119. package/dist/wait-for-device.js +368 -0
  120. package/dist/wda-manager.d.ts +38 -0
  121. package/dist/wda-manager.js +186 -0
  122. package/dist/wda-simulator.d.ts +28 -0
  123. package/dist/wda-simulator.js +257 -0
  124. package/package.json +38 -5
package/dist/index.js CHANGED
@@ -1,11 +1,4 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * @ceraph/react-native-mcp — MCP server for React Native / Expo development workflow.
4
- *
5
- * Auto-detects Expo vs bare React Native projects and uses the appropriate
6
- * commands. Provides tools for building, running, error capture, screen
7
- * interaction, and prebuild detection.
8
- */
9
2
  import { join } from "node:path";
10
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -13,39 +6,87 @@ import { z } from "zod";
13
6
  import { RNManager } from "./expo-manager.js";
14
7
  import { ScreenManager } from "./screen.js";
15
8
  import { PrebuildDetector } from "./prebuild-detector.js";
16
- // ---------------------------------------------------------------------------
17
- // Resolve the project working directory.
18
- // ---------------------------------------------------------------------------
19
- // Cache lives next to the consumer's project (where the RN/Expo app actually
20
- // lives) so the prebuild snapshot persists across MCP restarts and across
21
- // different install paths (npx cache vs. local checkout).
9
+ import { AppLifecycle } from "./app-lifecycle.js";
10
+ import { DeviceAutonomy } from "./device-autonomy.js";
11
+ import { IproxyManager } from "./iproxy-manager.js";
12
+ import { runPreflight } from "./preflight.js";
13
+ import { TargetResolver } from "./target.js";
14
+ import { WdaManager } from "./wda-manager.js";
15
+ import { bootSimulator } from "./simulator-boot.js";
16
+ import { waitForDevice } from "./wait-for-device.js";
17
+ import { syncCameraRegistry, relativeRegistryPath, } from "./camera-registry-sync.js";
18
+ import { addCameraImage, AddCameraImageError, } from "./camera-image-writer.js";
19
+ import { SignalListener } from "./signal-listener.js";
20
+ import { PermissionInterceptor, modeFromEnv, } from "./permission-interceptor.js";
21
+ import { detectMonorepoStatus, getInitStatus, injectBoot, installCeraphPackage, pollAuthFlow, preflightForMcp, registerScheme, replaceCameraView, scanCameraUsages, setupImagesDir, setupMcpClients, startAuthFlow, } from "./init/steps.js";
22
+ import { detectMacLanIp } from "./init/lan-ip.js";
23
+ import { registerUninstallTools } from "./uninstall/mcp-tools.js";
22
24
  const PROJECT_DIR = process.cwd();
23
25
  const CACHE_DIR = join(PROJECT_DIR, ".rn-mcp-cache");
24
- // ---------------------------------------------------------------------------
25
- // Initialize managers
26
- // ---------------------------------------------------------------------------
27
26
  const rnManager = new RNManager(PROJECT_DIR);
28
- const screenManager = new ScreenManager();
27
+ const targetResolver = new TargetResolver();
28
+ const screenManager = new ScreenManager({ targetResolver });
29
29
  const prebuildDetector = new PrebuildDetector(PROJECT_DIR, CACHE_DIR);
30
- // ---------------------------------------------------------------------------
31
- // Create MCP server
32
- // ---------------------------------------------------------------------------
30
+ const appLifecycle = new AppLifecycle(screenManager, targetResolver);
31
+ targetResolver.setAppLifecycle(appLifecycle);
32
+ const deviceAutonomy = new DeviceAutonomy(screenManager);
33
+ const wdaManager = new WdaManager(targetResolver, CACHE_DIR, PROJECT_DIR);
34
+ const iproxyManager = new IproxyManager();
35
+ const permissionInterceptor = new PermissionInterceptor({
36
+ screen: screenManager,
37
+ mode: modeFromEnv(process.env.CERAPH_AUTO_ACCEPT_PERMISSIONS),
38
+ });
39
+ const signalListenerPort = process.env.CERAPH_SIGNAL_PORT
40
+ ? Number(process.env.CERAPH_SIGNAL_PORT)
41
+ : undefined;
42
+ const signalListener = new SignalListener(rnManager, {
43
+ port: Number.isFinite(signalListenerPort) ? signalListenerPort : undefined,
44
+ });
33
45
  const server = new McpServer({
34
46
  name: "react-native-mcp",
35
- version: "0.1.0",
47
+ version: "0.3.1",
36
48
  }, {
37
49
  capabilities: {
38
50
  tools: {},
39
51
  },
40
- instructions: "React Native / Expo development workflow tools. Auto-detects project type. " +
41
- "Use rn_build_ios to build, rn_start to launch Metro, rn_get_errors to " +
42
- "check for problems, screen_tap and screen_find_and_tap for device " +
43
- "interaction with automatic pixel ratio correction, and rn_check_prebuild " +
44
- "to detect when a clean native rebuild is needed (Expo only).",
45
- });
46
- // ---------------------------------------------------------------------------
47
- // Tool: rn_build_ios
48
- // ---------------------------------------------------------------------------
52
+ instructions: "React Native / Expo development workflow tools. Auto-detects project type.\n\n" +
53
+ "HOW TO DRIVE THE APP:\n" +
54
+ "Compose screen_* and app_* primitives to exercise the app on a real iOS " +
55
+ "device or simulator. You plan the steps yourself; this MCP just drives " +
56
+ "WebDriverAgent. Use screen_screenshot and screen_get_source between " +
57
+ "actions to observe state and decide the next step. Typical scenarios: " +
58
+ "'tap the Premium plan and verify checkout opens', 'sign in as user X and " +
59
+ "confirm the dashboard loads', 'reproduce the bug where tapping save " +
60
+ "crashes the app'.\n\n" +
61
+ "STARTUP FLOW WHEN NO TARGET IS AVAILABLE:\n" +
62
+ " - User wants a quick start with no device attached → rn_boot_simulator " +
63
+ "(auto-picks newest iPhone), then rn_wda_start, then retry the original tool.\n" +
64
+ " - User wants to test on their phone (or CERAPH_TARGET=device is set) → " +
65
+ "tell the user to plug in their iPhone, then rn_wait_for_device (event-driven " +
66
+ "via usbmuxd, no timeout) — the original tool can be re-run as soon as that " +
67
+ "returns success.\n" +
68
+ " - Ambiguous first-time setup: prefer the simulator path unless the user " +
69
+ "has signalled device intent (mentioned their phone, set CERAPH_TARGET=device, " +
70
+ "etc). Simulator avoids cable/trust friction for the first happy path.\n\n" +
71
+ "WDA-direct primitives (you orchestrate): app_launch/terminate/activate, " +
72
+ "screen_tap, screen_find_and_tap, screen_swipe, screen_scroll_to, " +
73
+ "screen_long_press, screen_type, screen_clear_text, screen_press_key, " +
74
+ "screen_screenshot, screen_get_source, screen_wait_for, screen_assert_visible.\n\n" +
75
+ "Build with rn_build_ios, run Metro with rn_start, inspect errors with " +
76
+ "rn_get_errors, check rn_check_prebuild when native deps change (Expo only). " +
77
+ "Camera testing: test images for the React Native camera live in " +
78
+ "`.ceraph/camera-images/` in the user's repo. When the user provides " +
79
+ "an image for camera testing, paste it into that folder with a " +
80
+ "descriptive lowercase filename (e.g. `profile.jpg`, `id-card.png`, " +
81
+ "`product.jpg`). The filename without extension is the imageKey. " +
82
+ "In source code, replace `<CameraView>` (from expo-camera) with " +
83
+ "`<CeraphCamera imageKey=\"profile\" />` from " +
84
+ "`@ceraph/react-native-mcp/shim`, choosing the imageKey based on " +
85
+ "what the camera screen captures conceptually. Then call " +
86
+ "`rn_sync_camera_registry` to regenerate the registry. The " +
87
+ "`rn_preflight` tool will warn about any unwrapped `<CameraView>`, " +
88
+ "missing imageKey props, or orphan imageKey references.",
89
+ });
49
90
  server.tool("rn_build_ios", "Build and run the React Native app on an iOS device or simulator. " +
50
91
  "Auto-detects Expo vs bare React Native and uses the correct command. " +
51
92
  "Captures Xcode build output and returns structured error information. " +
@@ -61,10 +102,8 @@ server.tool("rn_build_ios", "Build and run the React Native app on an iOS device
61
102
  }, async ({ clean, device }) => {
62
103
  try {
63
104
  const result = await rnManager.runBuild({ clean, device });
64
- // On success, save a snapshot for future prebuild checks
65
105
  if (result.success) {
66
106
  await prebuildDetector.saveSnapshot().catch(() => {
67
- // Non-critical; don't fail the build result over this
68
107
  });
69
108
  }
70
109
  return {
@@ -89,9 +128,6 @@ server.tool("rn_build_ios", "Build and run the React Native app on an iOS device
89
128
  };
90
129
  }
91
130
  });
92
- // ---------------------------------------------------------------------------
93
- // Tool: rn_start
94
- // ---------------------------------------------------------------------------
95
131
  server.tool("rn_start", "Start the Metro dev server. Auto-detects Expo vs bare React Native. " +
96
132
  "Monitors console output for runtime errors and warnings.", {
97
133
  port: z
@@ -127,9 +163,6 @@ server.tool("rn_start", "Start the Metro dev server. Auto-detects Expo vs bare R
127
163
  };
128
164
  }
129
165
  });
130
- // ---------------------------------------------------------------------------
131
- // Tool: rn_get_errors
132
- // ---------------------------------------------------------------------------
133
166
  server.tool("rn_get_errors", "Return all captured errors from build and runtime. " +
134
167
  "Includes structured build errors (file, line, column, message), " +
135
168
  "runtime JS errors (message, stack trace), and warnings.", {}, async () => {
@@ -156,9 +189,6 @@ server.tool("rn_get_errors", "Return all captured errors from build and runtime.
156
189
  };
157
190
  }
158
191
  });
159
- // ---------------------------------------------------------------------------
160
- // Tool: rn_get_console
161
- // ---------------------------------------------------------------------------
162
192
  server.tool("rn_get_console", "Return recent console output from Metro dev server, " +
163
193
  "optionally filtered by log level.", {
164
194
  lines: z
@@ -203,9 +233,6 @@ server.tool("rn_get_console", "Return recent console output from Metro dev serve
203
233
  };
204
234
  }
205
235
  });
206
- // ---------------------------------------------------------------------------
207
- // Tool: rn_check_prebuild
208
- // ---------------------------------------------------------------------------
209
236
  server.tool("rn_check_prebuild", "Check if a clean Expo prebuild is needed (Expo projects only). " +
210
237
  "Compares current package.json dependencies, app.json config, and " +
211
238
  "ios/Podfile.lock against a cached snapshot from the last successful build.", {}, async () => {
@@ -246,9 +273,6 @@ server.tool("rn_check_prebuild", "Check if a clean Expo prebuild is needed (Expo
246
273
  };
247
274
  }
248
275
  });
249
- // ---------------------------------------------------------------------------
250
- // Tool: screen_tap
251
- // ---------------------------------------------------------------------------
252
276
  server.tool("screen_tap", "Tap at specific coordinates on the iOS device screen via WebDriverAgent. " +
253
277
  "Automatically corrects for pixel ratio mismatch when coordinates come " +
254
278
  "from a screenshot (divides by device pixel ratio). " +
@@ -305,9 +329,6 @@ server.tool("screen_tap", "Tap at specific coordinates on the iOS device screen
305
329
  };
306
330
  }
307
331
  });
308
- // ---------------------------------------------------------------------------
309
- // Tool: screen_find_and_tap
310
- // ---------------------------------------------------------------------------
311
332
  server.tool("screen_find_and_tap", "Find a UI element on the iOS device screen by text, accessibility label, " +
312
333
  "or element type, then tap its center. Uses the WebDriverAgent element tree. " +
313
334
  "If no match is found, returns a summary of visible elements for debugging. " +
@@ -388,9 +409,771 @@ server.tool("screen_find_and_tap", "Find a UI element on the iOS device screen b
388
409
  };
389
410
  }
390
411
  });
391
- // ---------------------------------------------------------------------------
392
- // Tool: rn_stop
393
- // ---------------------------------------------------------------------------
412
+ async function requireWDA() {
413
+ const available = await screenManager.isAvailable();
414
+ if (available)
415
+ return { ok: true };
416
+ return {
417
+ ok: false,
418
+ payload: {
419
+ success: false,
420
+ error: "WebDriverAgent is not reachable at localhost:8100. " +
421
+ "Ensure it is running on the device.",
422
+ },
423
+ };
424
+ }
425
+ function jsonText(value) {
426
+ return {
427
+ content: [
428
+ {
429
+ type: "text",
430
+ text: JSON.stringify(value, null, 2),
431
+ },
432
+ ],
433
+ };
434
+ }
435
+ function toolErrorContent(toolName, err) {
436
+ return {
437
+ content: [
438
+ {
439
+ type: "text",
440
+ text: `${toolName} failed: ${err instanceof Error ? err.message : String(err)}`,
441
+ },
442
+ ],
443
+ isError: true,
444
+ };
445
+ }
446
+ server.tool("screen_swipe", "Swipe on the iOS device screen. Direction is up/down/left/right; " +
447
+ "optional from-point and distance default to a center-screen swipe of " +
448
+ "~60% of the relevant axis. Coordinates are device-space by default; " +
449
+ "pass coordinateSource: 'screenshot' if `from` came from a screenshot.", {
450
+ direction: z.enum(["up", "down", "left", "right"]),
451
+ from: z
452
+ .object({ x: z.number(), y: z.number() })
453
+ .optional()
454
+ .describe("Optional swipe start point. Defaults to screen center."),
455
+ distancePx: z.number().optional().describe("Swipe distance in points."),
456
+ durationMs: z.number().optional().describe("Swipe duration in ms."),
457
+ coordinateSource: z.enum(["screenshot", "device"]).optional(),
458
+ }, async (args) => {
459
+ try {
460
+ const guard = await requireWDA();
461
+ if (!guard.ok)
462
+ return { ...jsonText(guard.payload), isError: true };
463
+ const res = await screenManager.swipe({
464
+ direction: args.direction,
465
+ from: args.from,
466
+ distancePx: args.distancePx,
467
+ durationMs: args.durationMs,
468
+ coordinateSource: args.coordinateSource,
469
+ });
470
+ return { ...jsonText(res), isError: !res.success };
471
+ }
472
+ catch (err) {
473
+ return {
474
+ content: [
475
+ {
476
+ type: "text",
477
+ text: `screen_swipe failed: ${err instanceof Error ? err.message : String(err)}`,
478
+ },
479
+ ],
480
+ isError: true,
481
+ };
482
+ }
483
+ });
484
+ server.tool("screen_scroll_to", "Repeatedly swipe until an element matching text/accessibilityLabel " +
485
+ "appears in the accessibility tree. Returns without tapping. Default " +
486
+ "direction is 'down' (swipe up), max 10 swipes.", {
487
+ text: z.string().optional(),
488
+ accessibilityLabel: z.string().optional(),
489
+ maxSwipes: z.number().optional(),
490
+ direction: z.enum(["up", "down"]).optional(),
491
+ }, async (args) => {
492
+ try {
493
+ const guard = await requireWDA();
494
+ if (!guard.ok)
495
+ return { ...jsonText(guard.payload), isError: true };
496
+ if (!args.text && !args.accessibilityLabel) {
497
+ return {
498
+ ...jsonText({
499
+ success: false,
500
+ error: "text or accessibilityLabel is required.",
501
+ }),
502
+ isError: true,
503
+ };
504
+ }
505
+ const res = await screenManager.scrollToElement({
506
+ text: args.text,
507
+ accessibilityLabel: args.accessibilityLabel,
508
+ }, { maxSwipes: args.maxSwipes, direction: args.direction });
509
+ return { ...jsonText(res), isError: !res.success };
510
+ }
511
+ catch (err) {
512
+ return {
513
+ content: [
514
+ {
515
+ type: "text",
516
+ text: `screen_scroll_to failed: ${err instanceof Error ? err.message : String(err)}`,
517
+ },
518
+ ],
519
+ isError: true,
520
+ };
521
+ }
522
+ });
523
+ server.tool("screen_long_press", "Long-press an element matched by text/accessibilityLabel. " +
524
+ "Default duration is 1000ms.", {
525
+ text: z.string().optional(),
526
+ accessibilityLabel: z.string().optional(),
527
+ durationMs: z.number().optional(),
528
+ }, async (args) => {
529
+ try {
530
+ const guard = await requireWDA();
531
+ if (!guard.ok)
532
+ return { ...jsonText(guard.payload), isError: true };
533
+ if (!args.text && !args.accessibilityLabel) {
534
+ return {
535
+ ...jsonText({
536
+ success: false,
537
+ error: "text or accessibilityLabel is required.",
538
+ }),
539
+ isError: true,
540
+ };
541
+ }
542
+ const res = await screenManager.longPressElement({
543
+ text: args.text,
544
+ accessibilityLabel: args.accessibilityLabel,
545
+ }, args.durationMs ?? 1000);
546
+ return { ...jsonText(res), isError: !res.success };
547
+ }
548
+ catch (err) {
549
+ return {
550
+ content: [
551
+ {
552
+ type: "text",
553
+ text: `screen_long_press failed: ${err instanceof Error ? err.message : String(err)}`,
554
+ },
555
+ ],
556
+ isError: true,
557
+ };
558
+ }
559
+ });
560
+ server.tool("screen_type", "Type text into the currently focused element. If hideKeyboardAfter is " +
561
+ "true, sends a newline after to dismiss the keyboard. Use screen_find_and_tap " +
562
+ "to focus an input field first if needed.", {
563
+ text: z.string(),
564
+ hideKeyboardAfter: z.boolean().optional(),
565
+ }, async ({ text, hideKeyboardAfter }) => {
566
+ try {
567
+ const guard = await requireWDA();
568
+ if (!guard.ok)
569
+ return { ...jsonText(guard.payload), isError: true };
570
+ const res = await screenManager.type(text, { hideKeyboardAfter });
571
+ return { ...jsonText(res), isError: !res.success };
572
+ }
573
+ catch (err) {
574
+ return {
575
+ content: [
576
+ {
577
+ type: "text",
578
+ text: `screen_type failed: ${err instanceof Error ? err.message : String(err)}`,
579
+ },
580
+ ],
581
+ isError: true,
582
+ };
583
+ }
584
+ });
585
+ server.tool("screen_clear_text", "Focus an input field and send backspaces equal to its current value length.", {
586
+ text: z.string().optional(),
587
+ accessibilityLabel: z.string().optional(),
588
+ }, async (args) => {
589
+ try {
590
+ const guard = await requireWDA();
591
+ if (!guard.ok)
592
+ return { ...jsonText(guard.payload), isError: true };
593
+ if (!args.text && !args.accessibilityLabel) {
594
+ return {
595
+ ...jsonText({
596
+ success: false,
597
+ error: "text or accessibilityLabel is required.",
598
+ }),
599
+ isError: true,
600
+ };
601
+ }
602
+ const res = await screenManager.clearText({
603
+ text: args.text,
604
+ accessibilityLabel: args.accessibilityLabel,
605
+ });
606
+ return { ...jsonText(res), isError: !res.success };
607
+ }
608
+ catch (err) {
609
+ return {
610
+ content: [
611
+ {
612
+ type: "text",
613
+ text: `screen_clear_text failed: ${err instanceof Error ? err.message : String(err)}`,
614
+ },
615
+ ],
616
+ isError: true,
617
+ };
618
+ }
619
+ });
620
+ server.tool("screen_press_key", "Press a hardware/system button: home, volumeUp, volumeDown, or lock.", {
621
+ key: z.enum(["home", "volumeUp", "volumeDown", "lock"]),
622
+ }, async ({ key }) => {
623
+ try {
624
+ const guard = await requireWDA();
625
+ if (!guard.ok)
626
+ return { ...jsonText(guard.payload), isError: true };
627
+ const res = await screenManager.pressKey(key);
628
+ return { ...jsonText(res), isError: !res.success };
629
+ }
630
+ catch (err) {
631
+ return {
632
+ content: [
633
+ {
634
+ type: "text",
635
+ text: `screen_press_key failed: ${err instanceof Error ? err.message : String(err)}`,
636
+ },
637
+ ],
638
+ isError: true,
639
+ };
640
+ }
641
+ });
642
+ server.tool("screen_screenshot", "Capture a base64 PNG screenshot of the current iOS screen via WebDriverAgent.", {}, async () => {
643
+ try {
644
+ const guard = await requireWDA();
645
+ if (!guard.ok)
646
+ return { ...jsonText(guard.payload), isError: true };
647
+ const res = await screenManager.screenshot();
648
+ return { ...jsonText(res), isError: !res.success };
649
+ }
650
+ catch (err) {
651
+ return {
652
+ content: [
653
+ {
654
+ type: "text",
655
+ text: `screen_screenshot failed: ${err instanceof Error ? err.message : String(err)}`,
656
+ },
657
+ ],
658
+ isError: true,
659
+ };
660
+ }
661
+ });
662
+ server.tool("screen_get_source", "Return the full WebDriverAgent accessibility tree as JSON. " +
663
+ "Useful for discovering element identifiers when no screenshot is available.", {}, async () => {
664
+ try {
665
+ const guard = await requireWDA();
666
+ if (!guard.ok)
667
+ return { ...jsonText(guard.payload), isError: true };
668
+ const res = await screenManager.getSource();
669
+ return { ...jsonText(res), isError: !res.success };
670
+ }
671
+ catch (err) {
672
+ return {
673
+ content: [
674
+ {
675
+ type: "text",
676
+ text: `screen_get_source failed: ${err instanceof Error ? err.message : String(err)}`,
677
+ },
678
+ ],
679
+ isError: true,
680
+ };
681
+ }
682
+ });
683
+ server.tool("screen_wait_for", "Poll the WDA source tree until an element matching the query is visible " +
684
+ "(or, if disappear is true, until it vanishes). Default timeout is 5s.", {
685
+ text: z.string().optional(),
686
+ accessibilityLabel: z.string().optional(),
687
+ type: z.string().optional(),
688
+ timeoutMs: z.number().optional(),
689
+ pollIntervalMs: z.number().optional(),
690
+ disappear: z.boolean().optional(),
691
+ }, async (args) => {
692
+ try {
693
+ const guard = await requireWDA();
694
+ if (!guard.ok)
695
+ return { ...jsonText(guard.payload), isError: true };
696
+ if (!args.text && !args.accessibilityLabel && !args.type) {
697
+ return {
698
+ ...jsonText({
699
+ success: false,
700
+ error: "text, accessibilityLabel, or type is required.",
701
+ }),
702
+ isError: true,
703
+ };
704
+ }
705
+ const res = await screenManager.waitFor({
706
+ text: args.text,
707
+ accessibilityLabel: args.accessibilityLabel,
708
+ type: args.type,
709
+ }, {
710
+ timeoutMs: args.timeoutMs,
711
+ pollIntervalMs: args.pollIntervalMs,
712
+ disappear: args.disappear,
713
+ });
714
+ return { ...jsonText(res), isError: !res.success };
715
+ }
716
+ catch (err) {
717
+ return {
718
+ content: [
719
+ {
720
+ type: "text",
721
+ text: `screen_wait_for failed: ${err instanceof Error ? err.message : String(err)}`,
722
+ },
723
+ ],
724
+ isError: true,
725
+ };
726
+ }
727
+ });
728
+ server.tool("screen_assert_visible", "Single-shot check: is the element currently visible? Pass invert: true " +
729
+ "to assert it is NOT visible.", {
730
+ text: z.string().optional(),
731
+ accessibilityLabel: z.string().optional(),
732
+ type: z.string().optional(),
733
+ invert: z.boolean().optional(),
734
+ }, async (args) => {
735
+ try {
736
+ const guard = await requireWDA();
737
+ if (!guard.ok)
738
+ return { ...jsonText(guard.payload), isError: true };
739
+ if (!args.text && !args.accessibilityLabel && !args.type) {
740
+ return {
741
+ ...jsonText({
742
+ success: false,
743
+ error: "text, accessibilityLabel, or type is required.",
744
+ }),
745
+ isError: true,
746
+ };
747
+ }
748
+ const query = {
749
+ text: args.text,
750
+ accessibilityLabel: args.accessibilityLabel,
751
+ type: args.type,
752
+ };
753
+ if (args.invert) {
754
+ const res = await screenManager.assertNotVisible(query);
755
+ return { ...jsonText(res), isError: !res.success || res.visible };
756
+ }
757
+ const res = await screenManager.assertVisible(query);
758
+ return { ...jsonText(res), isError: !res.success || !res.visible };
759
+ }
760
+ catch (err) {
761
+ return {
762
+ content: [
763
+ {
764
+ type: "text",
765
+ text: `screen_assert_visible failed: ${err instanceof Error ? err.message : String(err)}`,
766
+ },
767
+ ],
768
+ isError: true,
769
+ };
770
+ }
771
+ });
772
+ server.tool("app_launch", "Launch an iOS app by bundle ID on a connected real device (preferred) or " +
773
+ "the booted simulator (fallback). Terminates any existing instance first " +
774
+ "via devicectl --terminate-existing.", {
775
+ bundleId: z.string().describe("e.g., com.acme.myapp"),
776
+ }, async ({ bundleId }) => {
777
+ try {
778
+ const res = await appLifecycle.launchApp(bundleId);
779
+ return { ...jsonText(res), isError: !res.success };
780
+ }
781
+ catch (err) {
782
+ return {
783
+ content: [
784
+ {
785
+ type: "text",
786
+ text: `app_launch failed: ${err instanceof Error ? err.message : String(err)}`,
787
+ },
788
+ ],
789
+ isError: true,
790
+ };
791
+ }
792
+ });
793
+ server.tool("app_terminate", "Terminate an iOS app by bundle ID.", {
794
+ bundleId: z.string(),
795
+ }, async ({ bundleId }) => {
796
+ try {
797
+ const res = await appLifecycle.terminateApp(bundleId);
798
+ return { ...jsonText(res), isError: !res.success };
799
+ }
800
+ catch (err) {
801
+ return {
802
+ content: [
803
+ {
804
+ type: "text",
805
+ text: `app_terminate failed: ${err instanceof Error ? err.message : String(err)}`,
806
+ },
807
+ ],
808
+ isError: true,
809
+ };
810
+ }
811
+ });
812
+ server.tool("app_activate", "Bring an app to the foreground without a cold restart. Uses WDA's " +
813
+ "/wda/apps/launch, falls back to a fresh launch if WDA is unreachable.", {
814
+ bundleId: z.string(),
815
+ }, async ({ bundleId }) => {
816
+ try {
817
+ const res = await appLifecycle.activateApp(bundleId);
818
+ return { ...jsonText(res), isError: !res.success };
819
+ }
820
+ catch (err) {
821
+ return {
822
+ content: [
823
+ {
824
+ type: "text",
825
+ text: `app_activate failed: ${err instanceof Error ? err.message : String(err)}`,
826
+ },
827
+ ],
828
+ isError: true,
829
+ };
830
+ }
831
+ });
832
+ server.tool("app_list_installed", "List apps installed on the connected real device via " +
833
+ "`xcrun devicectl device info apps`. Cached for 30s.", {}, async () => {
834
+ try {
835
+ const res = await appLifecycle.listInstalledApps();
836
+ return { ...jsonText(res), isError: !res.success };
837
+ }
838
+ catch (err) {
839
+ return {
840
+ content: [
841
+ {
842
+ type: "text",
843
+ text: `app_list_installed failed: ${err instanceof Error ? err.message : String(err)}`,
844
+ },
845
+ ],
846
+ isError: true,
847
+ };
848
+ }
849
+ });
850
+ server.tool("app_active", "Return info about the foreground app via WDA's /wda/activeAppInfo " +
851
+ "(bundleId, pid, name).", {}, async () => {
852
+ try {
853
+ const res = await appLifecycle.getActiveApp();
854
+ return { ...jsonText(res), isError: !res.success };
855
+ }
856
+ catch (err) {
857
+ return {
858
+ content: [
859
+ {
860
+ type: "text",
861
+ text: `app_active failed: ${err instanceof Error ? err.message : String(err)}`,
862
+ },
863
+ ],
864
+ isError: true,
865
+ };
866
+ }
867
+ });
868
+ server.tool("device_ensure_awake", "Ensure the iOS device is awake and unlocked. Returns a structured " +
869
+ "result with remediation guidance when the device is locked.", {}, async () => {
870
+ try {
871
+ const res = await deviceAutonomy.ensureAwake();
872
+ return { ...jsonText(res), isError: !res.awake };
873
+ }
874
+ catch (err) {
875
+ return {
876
+ content: [
877
+ {
878
+ type: "text",
879
+ text: `device_ensure_awake failed: ${err instanceof Error ? err.message : String(err)}`,
880
+ },
881
+ ],
882
+ isError: true,
883
+ };
884
+ }
885
+ });
886
+ server.tool("device_lock_state", "Report the current device lock state: unlocked, locked, screen-off, or unknown.", {}, async () => {
887
+ try {
888
+ const state = await deviceAutonomy.getLockState();
889
+ return jsonText({ lockState: state });
890
+ }
891
+ catch (err) {
892
+ return {
893
+ content: [
894
+ {
895
+ type: "text",
896
+ text: `device_lock_state failed: ${err instanceof Error ? err.message : String(err)}`,
897
+ },
898
+ ],
899
+ isError: true,
900
+ };
901
+ }
902
+ });
903
+ server.tool("device_set_orientation", "Set the device orientation to portrait or landscape via WDA.", {
904
+ orientation: z.enum(["portrait", "landscape"]),
905
+ }, async ({ orientation }) => {
906
+ try {
907
+ const res = await deviceAutonomy.setOrientation(orientation);
908
+ return { ...jsonText(res), isError: !res.success };
909
+ }
910
+ catch (err) {
911
+ return {
912
+ content: [
913
+ {
914
+ type: "text",
915
+ text: `device_set_orientation failed: ${err instanceof Error ? err.message : String(err)}`,
916
+ },
917
+ ],
918
+ isError: true,
919
+ };
920
+ }
921
+ });
922
+ server.tool("rn_preflight", "Run every setup check we can before flows execute: WDA reachable, " +
923
+ "device connected, device awake, app installed, required env vars " +
924
+ "present, laptop WiFi. Returns a structured PreflightResult — when " +
925
+ "ok: false, surface the failing checks' remediation verbatim and " +
926
+ "DO NOT attempt to fix environment issues by editing source code. " +
927
+ "Call this once before a batch of rn_test_* runs.", {
928
+ bundleId: z
929
+ .string()
930
+ .optional()
931
+ .describe("Bundle ID of the app under test (e.g., com.acme.myapp). " +
932
+ "Required for the app-installed check."),
933
+ requiredEnv: z
934
+ .array(z.string())
935
+ .optional()
936
+ .describe("Env var names the planned flows reference. " +
937
+ "Each must be set + non-empty."),
938
+ expectedNetwork: z
939
+ .string()
940
+ .optional()
941
+ .describe("Optional WiFi SSID the user expects the laptop (and phone) " +
942
+ "to be on. Mismatch is a warning, not an error."),
943
+ }, async ({ bundleId, requiredEnv, expectedNetwork }) => {
944
+ try {
945
+ const result = await runPreflight({
946
+ screen: screenManager,
947
+ apps: appLifecycle,
948
+ autonomy: deviceAutonomy,
949
+ iproxyManager,
950
+ bundleId,
951
+ requiredEnv,
952
+ expectedNetwork,
953
+ projectDir: PROJECT_DIR,
954
+ target: targetResolver,
955
+ });
956
+ return { ...jsonText(result), isError: !result.ok };
957
+ }
958
+ catch (err) {
959
+ return {
960
+ content: [
961
+ {
962
+ type: "text",
963
+ text: `rn_preflight failed: ${err instanceof Error ? err.message : String(err)}`,
964
+ },
965
+ ],
966
+ isError: true,
967
+ };
968
+ }
969
+ });
970
+ server.tool("rn_sync_camera_registry", "Scan .ceraph/camera-images/ for test images and (re)generate the " +
971
+ "static `_registry.ts` the app imports at boot. The filename without " +
972
+ "extension becomes the imageKey passed to <CeraphCamera imageKey>. " +
973
+ "Call this after adding or removing image files manually (Finder, " +
974
+ "git pull). `ceraph_add_camera_image` already auto-syncs after each " +
975
+ "write — only call this tool for manual drops. Idempotent — does " +
976
+ "not rewrite the registry when it already matches the discovered set.", {}, async () => {
977
+ try {
978
+ const result = await syncCameraRegistry(PROJECT_DIR);
979
+ if (result.writtenOrUnchanged === "missing-dir") {
980
+ return {
981
+ ...jsonText({
982
+ registered: [],
983
+ registryPath: relativeRegistryPath(PROJECT_DIR, result.registryPath),
984
+ state: result.writtenOrUnchanged,
985
+ message: "Create `.ceraph/camera-images/` and add at least one image. " +
986
+ "Run this tool again to generate the registry.",
987
+ }),
988
+ isError: true,
989
+ };
990
+ }
991
+ return jsonText({
992
+ registered: result.registered.map((r) => r.key),
993
+ registryPath: relativeRegistryPath(PROJECT_DIR, result.registryPath),
994
+ state: result.writtenOrUnchanged,
995
+ });
996
+ }
997
+ catch (err) {
998
+ return {
999
+ content: [
1000
+ {
1001
+ type: "text",
1002
+ text: `rn_sync_camera_registry failed: ${err instanceof Error ? err.message : String(err)}`,
1003
+ },
1004
+ ],
1005
+ isError: true,
1006
+ };
1007
+ }
1008
+ });
1009
+ server.tool("ceraph_add_camera_image", "Drop a camera test image into `.ceraph/camera-images/` without the dev " +
1010
+ "having to copy files by hand. Pass `imageBase64` (the bytes — no " +
1011
+ "`data:` prefix needed) plus a lowercase hyphen-separated `imageKey` " +
1012
+ "(e.g. `handwriting-sample-1`). `contentType` is a hint only — the " +
1013
+ "extension is chosen from magic bytes (JPEG/PNG/WebP/HEIC). A " +
1014
+ "collision with an existing imageKey is rejected unless `overwrite: " +
1015
+ "true` is set. After writing, the static `_registry.ts` is " +
1016
+ "regenerated automatically (same step as `rn_sync_camera_registry`).", {
1017
+ imageKey: z
1018
+ .string()
1019
+ .describe("Lowercase + hyphen-separated stem (e.g. `profile`, `id-card`, " +
1020
+ "`handwriting-sample-1`). Becomes both the filename stem AND " +
1021
+ "the value devs pass to `<CeraphCamera imageKey>`."),
1022
+ imageBase64: z
1023
+ .string()
1024
+ .describe("Base64-encoded image payload. A `data:image/...;base64,...` " +
1025
+ "URI prefix is accepted and stripped automatically."),
1026
+ contentType: z
1027
+ .string()
1028
+ .optional()
1029
+ .describe("Optional MIME hint (e.g. `image/jpeg`). The actual extension is " +
1030
+ "chosen from magic-byte detection; if the hint mismatches, the " +
1031
+ "tool returns a warning but still writes using the detected type."),
1032
+ overwrite: z
1033
+ .boolean()
1034
+ .optional()
1035
+ .describe("Allow replacing an existing imageKey (across different " +
1036
+ "extensions). Defaults to false."),
1037
+ subDir: z
1038
+ .string()
1039
+ .optional()
1040
+ .describe("Optional single-segment subdirectory under " +
1041
+ "`.ceraph/camera-images/`. Most callers omit this. Must be " +
1042
+ "lowercase + hyphen-separated; no slashes, no `..`."),
1043
+ }, async ({ imageKey, imageBase64, contentType, overwrite, subDir }) => {
1044
+ try {
1045
+ const result = await addCameraImage(PROJECT_DIR, {
1046
+ imageKey,
1047
+ imageBase64,
1048
+ contentType,
1049
+ overwrite,
1050
+ subDir,
1051
+ });
1052
+ return jsonText({
1053
+ written: result.written,
1054
+ registry: result.registry,
1055
+ registered: result.registered,
1056
+ detectedContentType: result.detectedContentType,
1057
+ detectedExt: result.detectedExt,
1058
+ replaced: result.replaced,
1059
+ removedSiblingPath: result.removedSiblingPath,
1060
+ bytes: result.bytes,
1061
+ registryState: result.registryState,
1062
+ warnings: result.warnings,
1063
+ });
1064
+ }
1065
+ catch (err) {
1066
+ if (err instanceof AddCameraImageError) {
1067
+ return {
1068
+ ...jsonText({
1069
+ error: err.code,
1070
+ message: err.message,
1071
+ remediation: err.remediation,
1072
+ }),
1073
+ isError: true,
1074
+ };
1075
+ }
1076
+ return {
1077
+ content: [
1078
+ {
1079
+ type: "text",
1080
+ text: `ceraph_add_camera_image failed: ${err instanceof Error ? err.message : String(err)}`,
1081
+ },
1082
+ ],
1083
+ isError: true,
1084
+ };
1085
+ }
1086
+ });
1087
+ server.tool("rn_permission_auto_accept", "Toggle the iOS system-permission dialog interceptor for the running " +
1088
+ "MCP process. 'auto-accept' (default) taps the permissive button on " +
1089
+ "any known system dialog before each flow step; 'ask' invokes the " +
1090
+ "configured callback (defaults to accept); 'off' is a no-op. " +
1091
+ "Mode persists for the lifetime of this MCP process. To set the " +
1092
+ "default for new processes, export CERAPH_AUTO_ACCEPT_PERMISSIONS.", {
1093
+ mode: z.enum(["auto-accept", "ask", "off"]),
1094
+ }, async ({ mode }) => {
1095
+ try {
1096
+ const next = mode;
1097
+ permissionInterceptor.setMode(next);
1098
+ return {
1099
+ ...jsonText({
1100
+ mode: next,
1101
+ message: `Permission interceptor mode set to '${next}'.`,
1102
+ }),
1103
+ };
1104
+ }
1105
+ catch (err) {
1106
+ return {
1107
+ content: [
1108
+ {
1109
+ type: "text",
1110
+ text: `rn_permission_auto_accept failed: ${err instanceof Error ? err.message : String(err)}`,
1111
+ },
1112
+ ],
1113
+ isError: true,
1114
+ };
1115
+ }
1116
+ });
1117
+ server.tool("rn_reload", "Request a JS bundle reload on the running RN app by dispatching the " +
1118
+ "`ceraph://reload` deep link through WDA. Requires the app to have " +
1119
+ "called `installSignalCapture()` (or otherwise registered the " +
1120
+ "reload deep-link listener) at boot. Waits up to `timeoutMs` for " +
1121
+ "WDA to become responsive again, then captures a screenshot. " +
1122
+ "Dev-only; does nothing in a production build.", {
1123
+ timeoutMs: z
1124
+ .number()
1125
+ .optional()
1126
+ .describe("Max time in ms to wait for the app to come back after the " +
1127
+ "reload (default 10000)."),
1128
+ }, async ({ timeoutMs }) => {
1129
+ try {
1130
+ const guard = await requireWDA();
1131
+ if (!guard.ok)
1132
+ return { ...jsonText(guard.payload), isError: true };
1133
+ const dispatch = await screenManager.openUrl("ceraph://reload");
1134
+ if (!dispatch.success) {
1135
+ return {
1136
+ ...jsonText({
1137
+ success: false,
1138
+ error: dispatch.error ?? "openUrl failed",
1139
+ }),
1140
+ isError: true,
1141
+ };
1142
+ }
1143
+ const limit = timeoutMs ?? 10_000;
1144
+ const start = Date.now();
1145
+ let backUp = false;
1146
+ while (Date.now() - start < limit) {
1147
+ const ok = await screenManager.pingStatus().catch(() => false);
1148
+ if (ok) {
1149
+ backUp = true;
1150
+ break;
1151
+ }
1152
+ await new Promise((r) => setTimeout(r, 200));
1153
+ }
1154
+ const shot = await screenManager.screenshot().catch(() => undefined);
1155
+ return {
1156
+ ...jsonText({
1157
+ success: backUp,
1158
+ elapsedMs: Date.now() - start,
1159
+ screenshotBase64: shot?.base64,
1160
+ error: backUp ? undefined : `WDA did not return within ${limit}ms`,
1161
+ }),
1162
+ isError: !backUp,
1163
+ };
1164
+ }
1165
+ catch (err) {
1166
+ return {
1167
+ content: [
1168
+ {
1169
+ type: "text",
1170
+ text: `rn_reload failed: ${err instanceof Error ? err.message : String(err)}`,
1171
+ },
1172
+ ],
1173
+ isError: true,
1174
+ };
1175
+ }
1176
+ });
394
1177
  server.tool("rn_stop", "Stop all managed React Native processes (Metro dev server and/or build process).", {}, async () => {
395
1178
  try {
396
1179
  const stopped = await rnManager.stopAll();
@@ -428,13 +1211,440 @@ server.tool("rn_stop", "Stop all managed React Native processes (Metro dev serve
428
1211
  };
429
1212
  }
430
1213
  });
431
- // ---------------------------------------------------------------------------
432
- // Start the server
433
- // ---------------------------------------------------------------------------
1214
+ server.tool("rn_target_status", "Report which iOS target is currently selected (device vs simulator), " +
1215
+ "the resolved WDA base URL, whether a simulator WDA session is " +
1216
+ "running, and the CERAPH_TARGET env preference. Call this when the " +
1217
+ "developer asks `am I testing on device or simulator?` or before " +
1218
+ "starting a flow if uncertain which transport is active.", {}, async () => {
1219
+ try {
1220
+ targetResolver.invalidate();
1221
+ const info = await targetResolver.resolve();
1222
+ const session = wdaManager.getSession();
1223
+ return jsonText({
1224
+ target: info.target,
1225
+ udid: info.udid,
1226
+ baseUrl: info.baseUrl,
1227
+ reason: info.reason,
1228
+ preferenceEnv: process.env.CERAPH_TARGET ?? null,
1229
+ simulatorWdaSession: session
1230
+ ? { port: session.port, udid: session.udid, projectPath: session.projectPath }
1231
+ : null,
1232
+ });
1233
+ }
1234
+ catch (err) {
1235
+ return {
1236
+ content: [
1237
+ {
1238
+ type: "text",
1239
+ text: `rn_target_status failed: ${err instanceof Error ? err.message : String(err)}`,
1240
+ },
1241
+ ],
1242
+ isError: true,
1243
+ };
1244
+ }
1245
+ });
1246
+ server.tool("rn_wda_start", "Build and launch WebDriverAgent against a booted iOS simulator. " +
1247
+ "Required ONCE per MCP session when testing on the simulator (real " +
1248
+ "devices don't need this). Reads `appium-webdriveragent` from " +
1249
+ "node_modules (optional dep — install with `npm i -D " +
1250
+ "appium-webdriveragent` if missing). Captures the WDA port from " +
1251
+ "xcodebuild's output and routes all screen_*/app_* tools to the " +
1252
+ "simulator automatically. First-time build can take 60-120s and " +
1253
+ "may require an Xcode signing prompt. Idempotent — re-calling " +
1254
+ "returns the existing session.", {
1255
+ udid: z
1256
+ .string()
1257
+ .optional()
1258
+ .describe("Booted simulator UDID. When omitted, the first booted " +
1259
+ "simulator is used. Run `xcrun simctl list devices booted` " +
1260
+ "to see options."),
1261
+ startupTimeoutMs: z
1262
+ .number()
1263
+ .int()
1264
+ .positive()
1265
+ .optional()
1266
+ .describe("Max wait for the `ServerURLHere->...<-ServerURLHere` marker " +
1267
+ "in xcodebuild's stdout. Default 120000ms (cold-build safe)."),
1268
+ }, async ({ udid, startupTimeoutMs }) => {
1269
+ try {
1270
+ const result = await wdaManager.start({ udid, startupTimeoutMs });
1271
+ return { ...jsonText(result), isError: !result.ok };
1272
+ }
1273
+ catch (err) {
1274
+ return {
1275
+ content: [
1276
+ {
1277
+ type: "text",
1278
+ text: `rn_wda_start failed: ${err instanceof Error ? err.message : String(err)}`,
1279
+ },
1280
+ ],
1281
+ isError: true,
1282
+ };
1283
+ }
1284
+ });
1285
+ server.tool("rn_wda_stop", "Stop the simulator WebDriverAgent session started by rn_wda_start. " +
1286
+ "Idempotent — returns `{ stopped: false }` when no session is " +
1287
+ "active. Real-device WDA is unaffected (it's managed by Xcode, " +
1288
+ "not this MCP).", {}, async () => {
1289
+ try {
1290
+ const result = await wdaManager.stop();
1291
+ return jsonText(result);
1292
+ }
1293
+ catch (err) {
1294
+ return {
1295
+ content: [
1296
+ {
1297
+ type: "text",
1298
+ text: `rn_wda_stop failed: ${err instanceof Error ? err.message : String(err)}`,
1299
+ },
1300
+ ],
1301
+ isError: true,
1302
+ };
1303
+ }
1304
+ });
1305
+ server.tool("rn_boot_simulator", "Boot an iOS simulator from cold via `xcrun simctl`. Use this when " +
1306
+ "the developer doesn't have a real device plugged in and no " +
1307
+ "simulator is already booted — preflight will surface that state. " +
1308
+ "Auto-picks the newest available iPhone (preferring the plain " +
1309
+ "model like `iPhone 15` over `iPhone 15 Pro Max`) unless `udid` " +
1310
+ "or `deviceType` narrows the choice. Idempotent — returns " +
1311
+ "`{ alreadyBooted: true }` when the chosen sim is already running. " +
1312
+ "After this succeeds, call rn_wda_start to ready WebDriverAgent, " +
1313
+ "then retry the original test tool.", {
1314
+ udid: z
1315
+ .string()
1316
+ .optional()
1317
+ .describe("Boot a specific simulator by UDID. When set, discovery is " +
1318
+ "skipped (deviceType is ignored). Run `xcrun simctl list " +
1319
+ "devices` to see options."),
1320
+ deviceType: z
1321
+ .string()
1322
+ .optional()
1323
+ .describe("Substring match against simulator names (case-insensitive, " +
1324
+ "e.g. `iPhone 15`, `iPhone 14 Pro`). Picks the newest iOS " +
1325
+ "runtime among matches. Ignored when `udid` is set."),
1326
+ bootTimeoutMs: z
1327
+ .number()
1328
+ .int()
1329
+ .positive()
1330
+ .optional()
1331
+ .describe("Max wait for `simctl bootstatus -b` to confirm the simulator " +
1332
+ "has settled. Default 120000ms (first boots of a fresh " +
1333
+ "runtime can take 60-90s)."),
1334
+ }, async ({ udid, deviceType, bootTimeoutMs }) => {
1335
+ try {
1336
+ const result = await bootSimulator({ udid, deviceType, bootTimeoutMs });
1337
+ targetResolver.invalidate();
1338
+ return jsonText(result);
1339
+ }
1340
+ catch (err) {
1341
+ return toolErrorContent("rn_boot_simulator", err);
1342
+ }
1343
+ });
1344
+ server.tool("rn_wait_for_device", "Wait for an iPhone to be connected via USB-C. Uses macOS's " +
1345
+ "usbmuxd daemon for sub-second event-driven detection (no " +
1346
+ "polling). Waits indefinitely until either a device connects or " +
1347
+ "the agent aborts the call. Use this after telling the user " +
1348
+ "'please plug in your iPhone' — the call resolves immediately " +
1349
+ "when they do, with zero detection lag.", {
1350
+ timeoutMs: z
1351
+ .number()
1352
+ .int()
1353
+ .positive()
1354
+ .optional()
1355
+ .describe("Optional explicit deadline in milliseconds. Default: none — " +
1356
+ "the call waits forever and relies on the MCP abort signal " +
1357
+ "for cancellation. Set this only when you want a hard cap."),
1358
+ }, async ({ timeoutMs }, extra) => {
1359
+ try {
1360
+ const result = await waitForDevice({
1361
+ timeoutMs,
1362
+ signal: extra.signal,
1363
+ });
1364
+ targetResolver.invalidate();
1365
+ return jsonText(result);
1366
+ }
1367
+ catch (err) {
1368
+ return toolErrorContent("rn_wait_for_device", err);
1369
+ }
1370
+ });
1371
+ function initToolResult(payload) {
1372
+ return {
1373
+ content: [
1374
+ { type: "text", text: JSON.stringify(payload, null, 2) },
1375
+ ],
1376
+ };
1377
+ }
1378
+ function initToolError(toolName, err) {
1379
+ return {
1380
+ content: [
1381
+ {
1382
+ type: "text",
1383
+ text: `${toolName} failed: ${err instanceof Error ? err.message : String(err)}`,
1384
+ },
1385
+ ],
1386
+ isError: true,
1387
+ };
1388
+ }
1389
+ server.tool("ceraph_init_status", "Snapshot the current init state of the project: which steps are " +
1390
+ "complete (package installed, GitHub auth cached, ceraph:// scheme " +
1391
+ "registered, installCeraph() injected, CameraView usages found) and " +
1392
+ "which MCP-client config files exist. Use this FIRST to decide which " +
1393
+ "individual init tools still need to run.", {
1394
+ projectDir: z
1395
+ .string()
1396
+ .optional()
1397
+ .describe("Directory to inspect. Defaults to the MCP server's cwd " +
1398
+ "(usually the user's project root)."),
1399
+ }, async ({ projectDir }) => {
1400
+ try {
1401
+ const result = await getInitStatus(projectDir ?? PROJECT_DIR);
1402
+ return initToolResult(result);
1403
+ }
1404
+ catch (err) {
1405
+ return initToolError("ceraph_init_status", err);
1406
+ }
1407
+ });
1408
+ server.tool("ceraph_init_install_package", "Detect the project's package manager (pnpm/yarn/bun/npm via lockfile) " +
1409
+ "and install @ceraph/react-native-mcp into dependencies. Idempotent — " +
1410
+ "returns `{ already: true }` if the package is already in package.json.", {
1411
+ projectDir: z.string().optional(),
1412
+ }, async ({ projectDir }) => {
1413
+ try {
1414
+ const result = await installCeraphPackage(projectDir ?? PROJECT_DIR);
1415
+ return initToolResult(result);
1416
+ }
1417
+ catch (err) {
1418
+ return initToolError("ceraph_init_install_package", err);
1419
+ }
1420
+ });
1421
+ server.tool("ceraph_init_auth_start", "Begin the GitHub OAuth device flow. Returns { verificationUri, " +
1422
+ "userCode, pollUrl } — surface verificationUri + userCode to the user " +
1423
+ "VERBATIM (don't paraphrase), then call ceraph_init_auth_poll with " +
1424
+ "pollUrl in a short loop (every ~5 seconds) until status is 'complete' " +
1425
+ "or 'failed'.", {}, async () => {
1426
+ try {
1427
+ const result = await startAuthFlow();
1428
+ return initToolResult(result);
1429
+ }
1430
+ catch (err) {
1431
+ return initToolError("ceraph_init_auth_start", err);
1432
+ }
1433
+ });
1434
+ server.tool("ceraph_init_auth_poll", "Poll the in-progress device flow once. Returns one of: " +
1435
+ "{ status: 'pending' }, { status: 'slow_down', intervalSeconds }, " +
1436
+ "{ status: 'complete', login }, or { status: 'failed', reason }. " +
1437
+ "Wait `intervalSeconds` before the next call on slow_down. Stop when " +
1438
+ "status === 'complete' or 'failed'. On 'failed', restart with " +
1439
+ "ceraph_init_auth_start (NOT a retry of the same pollUrl).", {
1440
+ pollUrl: z
1441
+ .string()
1442
+ .describe("Opaque handle returned by ceraph_init_auth_start."),
1443
+ }, async ({ pollUrl }) => {
1444
+ try {
1445
+ const result = await pollAuthFlow(pollUrl);
1446
+ return initToolResult(result);
1447
+ }
1448
+ catch (err) {
1449
+ return initToolError("ceraph_init_auth_poll", err);
1450
+ }
1451
+ });
1452
+ server.tool("ceraph_init_scan_camera", "AST-scan the project for <CameraView> usages (from expo-camera). " +
1453
+ "Returns each usage with its file path, line, suggested imageKey " +
1454
+ "(heuristic), and ~30 lines of surrounding code so the AI can " +
1455
+ "reason about what to put in imageKey. The AI should override the " +
1456
+ "suggestion based on context (e.g. a `project.type === 'uppercase'` " +
1457
+ "branch nearby implies imageKey='letter-uppercase').", {
1458
+ projectDir: z.string().optional(),
1459
+ }, async ({ projectDir }) => {
1460
+ try {
1461
+ const result = await scanCameraUsages(projectDir ?? PROJECT_DIR);
1462
+ return initToolResult(result);
1463
+ }
1464
+ catch (err) {
1465
+ return initToolError("ceraph_init_scan_camera", err);
1466
+ }
1467
+ });
1468
+ server.tool("ceraph_init_replace_camera", "Apply ONE <CameraView> → <CeraphCamera imageKey=\"...\"> AST " +
1469
+ "replacement. The AI provides the imageKey based on the context it " +
1470
+ "saw in scan_camera's surroundingCode. Also adds the shim import " +
1471
+ "and trims `CameraView` from the expo-camera import when no JSX " +
1472
+ "uses it anymore. Idempotent — returns `{ already: true }` when " +
1473
+ "the line is already CeraphCamera.", {
1474
+ filePath: z.string().describe("Absolute path of the source file."),
1475
+ line: z
1476
+ .number()
1477
+ .int()
1478
+ .positive()
1479
+ .describe("1-based line of the opening <CameraView> tag."),
1480
+ imageKey: z
1481
+ .string()
1482
+ .min(1)
1483
+ .describe("The imageKey to use. Should be lowercase-with-dashes."),
1484
+ projectDir: z.string().optional(),
1485
+ }, async ({ filePath, line, imageKey, projectDir }) => {
1486
+ try {
1487
+ const result = await replaceCameraView(projectDir ?? PROJECT_DIR, {
1488
+ filePath,
1489
+ line,
1490
+ imageKey,
1491
+ });
1492
+ return initToolResult(result);
1493
+ }
1494
+ catch (err) {
1495
+ return initToolError("ceraph_init_replace_camera", err);
1496
+ }
1497
+ });
1498
+ server.tool("ceraph_init_inject_boot", "AST-inject `useEffect(() => { installCeraph(); }, [])` plus the " +
1499
+ "shim + react imports into the root layout (app/_layout.tsx for " +
1500
+ "Expo Router, App.tsx for bare RN). Idempotent — returns " +
1501
+ "`{ already: true }` when installCeraph() is already present.", {
1502
+ projectDir: z.string().optional(),
1503
+ }, async ({ projectDir }) => {
1504
+ try {
1505
+ const result = await injectBoot(projectDir ?? PROJECT_DIR);
1506
+ return initToolResult(result);
1507
+ }
1508
+ catch (err) {
1509
+ return initToolError("ceraph_init_inject_boot", err);
1510
+ }
1511
+ });
1512
+ server.tool("ceraph_init_register_scheme", "Register `ceraph` as a custom URL scheme so deep links " +
1513
+ "(ceraph://test-image, ceraph://reload, ceraph://reset) reach the " +
1514
+ "running app. Auto-edits static expo.scheme in app.json; for " +
1515
+ "dynamic app.config.{js,ts} or bare RN, returns the exact " +
1516
+ "instructions to surface to the user.", {
1517
+ projectDir: z.string().optional(),
1518
+ }, async ({ projectDir }) => {
1519
+ try {
1520
+ const result = await registerScheme(projectDir ?? PROJECT_DIR);
1521
+ return initToolResult(result);
1522
+ }
1523
+ catch (err) {
1524
+ return initToolError("ceraph_init_register_scheme", err);
1525
+ }
1526
+ });
1527
+ server.tool("ceraph_init_setup_images_dir", "Create the `.ceraph/camera-images/` directory with a README that " +
1528
+ "documents the imageKey naming convention, and append " +
1529
+ "`.rn-errors.json`, `.rn-flow-progress.json`, `.rn-mcp-cache/` to " +
1530
+ ".gitignore. Idempotent — second invocation reports no changes.", {
1531
+ projectDir: z.string().optional(),
1532
+ }, async ({ projectDir }) => {
1533
+ try {
1534
+ const result = await setupImagesDir(projectDir ?? PROJECT_DIR);
1535
+ return initToolResult(result);
1536
+ }
1537
+ catch (err) {
1538
+ return initToolError("ceraph_init_setup_images_dir", err);
1539
+ }
1540
+ });
1541
+ server.tool("ceraph_init_setup_mcp_clients", "Write MCP server config blocks to every supported editor's local " +
1542
+ "config file: .mcp.json (Claude Code), .cursor/mcp.json (Cursor), " +
1543
+ ".codex/config.toml (Codex), .vscode/mcp.json (VS Code). Returns " +
1544
+ "{ written: string[], skipped: string[] }. Skip = already had the " +
1545
+ "react-native-mcp entry.", {
1546
+ projectDir: z.string().optional(),
1547
+ }, async ({ projectDir }) => {
1548
+ try {
1549
+ const result = await setupMcpClients(projectDir ?? PROJECT_DIR);
1550
+ return initToolResult(result);
1551
+ }
1552
+ catch (err) {
1553
+ return initToolError("ceraph_init_setup_mcp_clients", err);
1554
+ }
1555
+ });
1556
+ server.tool("ceraph_init_preflight", "Run the full preflight check suite (Xcode, WDA, device, app, " +
1557
+ "camera-images sync, etc.) and return the structured result. Use " +
1558
+ "this AFTER the other init tools to verify the project is ready " +
1559
+ "to run flows.", {
1560
+ projectDir: z.string().optional(),
1561
+ }, async ({ projectDir }) => {
1562
+ try {
1563
+ const result = await preflightForMcp(projectDir ?? PROJECT_DIR, {
1564
+ screen: screenManager,
1565
+ apps: appLifecycle,
1566
+ autonomy: deviceAutonomy,
1567
+ target: targetResolver,
1568
+ iproxyManager,
1569
+ });
1570
+ return initToolResult(result);
1571
+ }
1572
+ catch (err) {
1573
+ return initToolError("ceraph_init_preflight", err);
1574
+ }
1575
+ });
1576
+ server.tool("ceraph_init_monorepo_status", "Inspect the project root for monorepo signals (package.json " +
1577
+ "workspaces, pnpm-workspace.yaml, lerna.json) and return any RN/Expo " +
1578
+ "subpackages found. If the cwd is itself an RN app, " +
1579
+ "`rootIsRnApp: true` and `matches: []`. Use this before calling other " +
1580
+ "init tools — when matches.length === 1, pass that absPath as " +
1581
+ "projectDir to every subsequent ceraph_init_* call.", {
1582
+ projectDir: z.string().optional(),
1583
+ }, async ({ projectDir }) => {
1584
+ try {
1585
+ const result = await detectMonorepoStatus(projectDir ?? PROJECT_DIR);
1586
+ return initToolResult(result);
1587
+ }
1588
+ catch (err) {
1589
+ return initToolError("ceraph_init_monorepo_status", err);
1590
+ }
1591
+ });
1592
+ registerUninstallTools(server);
434
1593
  async function main() {
435
1594
  const transport = new StdioServerTransport();
436
1595
  await server.connect(transport);
437
1596
  console.error("[react-native-mcp] Server started on stdio transport");
1597
+ if (!process.env.CERAPH_SIGNAL_HOST) {
1598
+ try {
1599
+ const detected = detectMacLanIp();
1600
+ if (detected) {
1601
+ process.env.CERAPH_SIGNAL_HOST = detected.address;
1602
+ console.error(`[react-native-mcp] CERAPH_SIGNAL_HOST auto-detected: ${detected.address} ` +
1603
+ `(${detected.interfaceName}, ${detected.kind}). To persist across shells, ` +
1604
+ `run \`npx @ceraph/react-native-mcp init\` — it adds the export to ~/.zshrc.`);
1605
+ }
1606
+ else {
1607
+ console.error("[react-native-mcp] CERAPH_SIGNAL_HOST is unset and no LAN IP was " +
1608
+ "auto-detectable. Real-device shim signals will default to " +
1609
+ "localhost (which the device cannot reach back to the Mac). " +
1610
+ "Set it manually: export CERAPH_SIGNAL_HOST=<your-mac-ip>");
1611
+ }
1612
+ }
1613
+ catch (err) {
1614
+ console.error(`[react-native-mcp] CERAPH_SIGNAL_HOST auto-detection failed: ${err instanceof Error ? err.message : String(err)}`);
1615
+ }
1616
+ }
1617
+ else {
1618
+ console.error(`[react-native-mcp] CERAPH_SIGNAL_HOST=${process.env.CERAPH_SIGNAL_HOST}`);
1619
+ }
1620
+ const listenResult = await signalListener.start();
1621
+ if (listenResult.ok) {
1622
+ console.error(`[react-native-mcp] Signal listener bound on 127.0.0.1:${listenResult.port}`);
1623
+ }
1624
+ else if (listenResult.errorCode === "EADDRINUSE") {
1625
+ console.error(`[react-native-mcp] ERROR: Port ${listenResult.port} is already in use.\n` +
1626
+ "Another Ceraph MCP server is already running on this machine.\n" +
1627
+ "Stop the existing process before starting a new one.\n" +
1628
+ "(WDA port 8100 is owned by WDA itself and is a separate concern.)");
1629
+ process.exit(1);
1630
+ }
1631
+ else {
1632
+ console.error(`[react-native-mcp] Signal listener failed to bind on port ${listenResult.port}: ${listenResult.error}. ` +
1633
+ "Shim-side signals (JS errors, unhandled rejections, network failures) will be dropped.");
1634
+ }
1635
+ const shutdown = async () => {
1636
+ await Promise.all([
1637
+ signalListener.stop().catch(() => undefined),
1638
+ iproxyManager.stop().catch(() => undefined),
1639
+ ]);
1640
+ await wdaManager.stop().catch(() => undefined);
1641
+ };
1642
+ process.on("SIGINT", () => {
1643
+ void shutdown().then(() => process.exit(0));
1644
+ });
1645
+ process.on("SIGTERM", () => {
1646
+ void shutdown().then(() => process.exit(0));
1647
+ });
438
1648
  }
439
1649
  main().catch((err) => {
440
1650
  console.error("[react-native-mcp] Fatal error:", err);