react-native-eye
Enables AI agents to observe and control React Native apps running on Android emulators or devices, including screenshots, layout inspection, taps, typing, app launching, permissions, and video recording via adb and a native instrumentation driver.
Supports connecting to Expo SDK 54+ dev builds, allowing agents to interact with Expo-based React Native apps through Metro and Hermes without adding an SDK or code changes.
Enables AI agents to observe and control React Native apps running on iOS simulators or devices, including screenshots, layout inspection, taps, typing, app launching, permissions, and video recording via XCUITest/AXe.
Connects to Metro's Chrome DevTools Protocol endpoint to reach the app's JS runtime, enabling layout inspection, input, logs, network, and state access without app code changes.
Integrates with React's fiber tree in React Native apps to measure elements and invoke press and text handlers directly for fast, JS-based input and layout retrieval.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@react-native-eyeConnect to my app, tap the login button, and check the logs for errors."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
react-native-eye connects Claude Code, Cursor or any other MCP client to your app through Metro's Chrome DevTools Protocol endpoint. The agent reads the screen, taps and types, and checks logs, network and state. The app needs no SDK and no code changes.
It is built for agent speed. Composite tools run a whole interaction in one call, so the agent does not pay one model round trip per tap.
Contents
Related MCP server: React Native MCP Server
Quick start
1. Add the server to your MCP client.
Claude Code, from your project folder:
claude mcp add react-native-eye -- npx -y react-native-eye@latestCursor (.cursor/mcp.json), Claude Desktop and any other client that takes the standard config:
{
"mcpServers": {
"react-native-eye": {
"command": "npx",
"args": ["-y", "react-native-eye@latest"]
}
}
}Codex (~/.codex/config.toml):
[mcp_servers.react-native-eye]
command = "npx"
args = ["-y", "react-native-eye@latest"]2. Start Metro and a dev build. Run the app in an iOS simulator or an Android emulator the way you usually do. Metro on the standard ports (8081–8083, 19000–19002) is found automatically. For any other port, pass --port 8090 in args, set METRO_PORT, or give the port to the connect tool.
3. Ask the agent.
Connect to my app and tell me what is on the screen.
The agent calls connect, then observe. The first call on each device installs the native driver once.
Requirements
Node | 18 or newer |
App | React Native 0.70+ or Expo SDK 54+, as a dev build with Metro and Hermes running |
iOS | macOS with Xcode and a simulator. AXe CLI ( |
Android |
|
OCR ( | A macOS host with the Xcode Command Line Tools ( |
The JS paths (layout from the runtime, via: "js" taps and typing) need only a connected app.
What the agent can do
35 tools in five groups. Tools in bold are composite: one call instead of many. Prefer them.
Group | Tools |
See the screen |
|
Act |
|
Debug |
|
Measure |
|
Manage devices |
|
The composite tools:
Tool | What it does |
| Screenshot + interactive elements + recent errors + recent logs, in one call |
| A whole UI flow server-side: taps, typing, waits, assertions — aborts instantly on app errors, returns per-step results + an observe at the stop point |
| Current route + store state + unhandled errors |
A whole flow in one call
{ "steps": [
{ "type": { "text": "a@b.c", "into": { "placeholder": "Email" } } },
{ "type": { "text": "hunter2", "into": { "placeholder": "Password" } } },
{ "tap": { "text": "Log in", "via": "os" } },
{ "wait_for": { "text": "Home", "timeout_ms": 3000 } },
{ "expect_not": { "text": "Error" } }
] }Each step takes exactly one action (settle is a modifier) — a step with two actions is a validation error, not a silently skipped assertion. Steps run back-to-back with adaptive settling (two rendered frames, capped at 1s; override per step with "settle": 200). A redbox or uncaught exception anywhere in the flow aborts it immediately with full context, and steps that never ran are listed as such.
tap and type steps take the same selectors, via and into as the standalone tools — into accepts {testID}, {placeholder} or {label}, and a bare string is still a testID — and each step's result names the path it used. A step that asks for via: "os" gets a real touch even when a JS press was available: use it for anything driven by the responder lifecycle, and leave the default auto everywhere else.
How it works
The server reaches the app two ways and picks the cheaper one for each call.
Through the JS runtime. The server walks React's fiber tree over CDP. It measures elements and calls press and text handlers directly, with no touch involved. A press takes about a millisecond and a full-screen layout about 80 ms.
Through the device. A prebuilt driver ships in the npm package and installs itself on first use: an XCUITest runner on iOS, an instrumentation server on Android. It delivers real touches for gestures, system dialogs and native views, and types any Unicode text on Android. If it cannot start, input falls back to axe or adb.
No silent switching. Every response says which path ran: (input: js), (input: os/runner), (layout: js), (layout: os), with the reason when a path was skipped.
Measured in the project's own field tests against a busy video feed:
Native driver | The tool it replaces | |
iOS simulator, one feed swipe, median of 40 | 0.93 s | axe: 26.68 s, 3 of 40 timed out at 90 s |
Android emulator, view hierarchy over a playing video, median of 10 | 4.60 s, 10 of 10 answered |
|
Tool reference
Three parameters appear on many tools.
device— on every device-facing tool (tap,swipe,scroll_to,type_text,press_key,screenshot,get_layout,read_screen_text,observe,run_flow,launch_app,install_app,open_url,set_permission,record_video): a udid or device-name substring. Without it the device is taken from the connected app's target, and two booted devices with no hint is an error rather than a silent guess.via— ontap,type_text,scroll_to,swipe,press_keyand thetap/typesteps ofrun_flow:"js" | "os" | "auto", defaultauto.jsgoes through the app's own fiber tree — the element under the resolved point is found, its nearestonPress/onChangeTextis called directly, and the whole round trip is about a millisecond.osdelivers a real touch or HID keystroke through the simulator or device.autotriesjsand falls back toos, naming the reason.Every response says which path ran —
(input: js), or(input: os/axe — no press handler in ancestry)(os/runner,os/axeoros/adbnames the OS mechanism that delivered it) — mirroring(layout: js)/(layout: os).Selector, ambiguity and visibility rules are identical on both paths: the JS path replaces delivery, not judgement, so an off-screen or hidden element is refused either way.
connect—port,target_id(fromlist_targets), ordevice.
tap—duration_msfor a long press (also available onrun_flowtap steps). On the JS path aduration_msof 500 or more calls the element'sonLongPress; an element that has none falls back toos, which is what the response then says.swipe—directionorx1,y1,x2,y2,duration_ms,via. The JS path scrolls the container under the start point by the gesture's own vector (FlatList.scrollToOffset 0 → 640), so a feed scrolls in a millisecond instead of a 20 s touch; no scroll container under the point falls back tooswith the reason.scroll_to—{text | testID}, plusviaandmax_swipes(default 10): bring an element into view. The JS path finds the element's nearest scrollable ancestor and scrolls that container (ScrollView,FlatList/VirtualizedList, FlashList,@legendapp/list; an unrecognised container falls back toos). The OS path swipes toward the element, re-reading the layout after each swipe and stopping the moment it is visible. This is the missing half oftap's "element is off-screen — scroll to it first".type_text—intochooses the field:{"testID": "…"},{"placeholder": "…"}or{"label": "…"}(accessibilityLabel); a bare string is still read as a testID. Omitted, it types into the oneTextInputon the focused, topmost screen and refuses — listing the candidates by placeholder and position — when there is more than one. Two instances of the same screen in a stack (each with the same composer) are told apart by react-navigation / react-native-screens state: the field on the focused screen wins and the response says so; a match only on a background screen is typed into with a warning.submit: truefires the field's submit handlers after typing (seepress_key enter).verify(defaulttrue) reads the field back after typing and fails loudly, naming both strings, when what arrived is not what was sent.viaas above; the JS path callsonChangeText, so the app's real pipeline (debounce, request, re-render) runs.press_key—key: back | home | enter,via,into.backon the JS path calls the react-navigation container'sgoBack(); an empty stack is reported asDid not go back: nothing to go back toand deliberately does not fall through to the hardware key (on Android that would background the app).enterfires the field's ownonSubmitEditing→onEndEditing→onBlur(JS only;intopicks the field, otherwise the focused one).navigate—action: stack | navigate | push | back | pop | pop_to_top | reset, withname/params(navigate, push),count(pop),state(reset). The react-navigation container is found by walking the React tree for its ref context (react-navigation 6/7, Expo Router), cached onglobalThis.__RNE_NAV__; no app change needed.stackprints the focused branch level by level; every mutating action returns the new stack, and an action no navigator handled is an error carrying react-navigation's own message.run_flow—observecontrols the trailing snapshot:auto(default) attaches a screenshot and adds the full layout only when the flow failed,fullalways,nonenever.
get_layout—search(terms matched against text/testID),type,match: substring|exact,case_sensitive, orat: {x, y}to identify the element at a point.read_screen_text— on-host OCR of the current screen (Apple Vision, nothing leaves the machine): one line per text run as<text> @x,y, coordinates in tap units usable directly withtap {x,y}.search(substring filters),languages(e.g.["de","en"]; auto-detected by default),compare_tree(default true — lines that also exist in the JS layout are marked[tree]; prefertap {text}for those). Sees text the layout tree cannot: images, canvases, WebViews, native dialogs.screenshot—output_pathsaves to a file (format: png|jpeg,maskon iOS) and returns the path instead of an inline image.get_component_tree—max_depth. The budget counts only components a developer actually authored: host components (RCTView,RCTText, ...) and structural wrappers (forwardRef/memo/context, styling HOCs like NativeWind'scssInterop) are free and can repeat any number of times without spending depth. RN's own dev-only infrastructure (LogBox, the debugging overlay, the root tag context) is dropped from the output entirely, not just made free. A very large tree is capped and markedtruncatedrather than returned unbounded.
get_state—path(dot-path) anddepth; the value is summarised inside the app, so the result is always valid JSON.foundsays whether a store exists at all;resolvedsays whether the path walked all the way to a value — a mistyped segment reportsresolved:falsewithfailedAtandavailableKeysinstead of looking identical to an honestundefined.eval_js—timeout_msfor a legitimately slow promise (default 10s).background: truestarts the evaluation as a job and returns its id at once, for a measurement that outlives a tool call or a JS thread that is busy right now; the CDP message is queued by Hermes and runs when the thread frees.labelnames the job.get_job—idreturns a background job's status and value (or error); withoutid, lists jobs.profile_rendersalso runs as a job withbackground: true, so a profile can be driven withtap/swipewhile it collects.get_network—search(URL substring to keep),exclude(URL substrings to drop),include_dev(show dev-tooling noise: Metro/devtools ports and/symbolicate,/logs,/statuson local hosts are hidden by default — the footer says how many). Query strings are cut short in the list;detail: <seq>has the full URL and bodies.
All four work on a dev build with zero app setup.
Tool | What it does |
| Which components rendered, how often, and how long, over a window: per-commit render roots with why (changed props / own state / mount), self time per component from React's profiler fields — computed per commit, not the accumulated |
| One-shot Hermes CPU profile over CDP: busy/idle/GC split, top functions by self and inclusive time, symbolicated through Metro; the raw |
| CPU by thread from the host: main vs JS vs render/shadow, share of the app's work and busy fraction per thread, top native frames (macOS |
| Frame timing: real UI-thread frames on Android ( |
profile_renders—duration_ms(default 5s),top,background. Needs a dev build (the React DevTools hook is what it wraps).profile_js—duration_ms(default 5s),top,sampling_interval_us(Hermes may ignore it — the output says so),save(default true, to$RNE_OUTPUT_DIRor~/Downloads).sample_cpu—duration_s(default 3),app_idwhen the connected target does not name the app.get_fps—duration_ms(default 2s),app_id. On iOS only the JS-thread number is available without native code: UI-thread frames need a physical device and xctrace's Core Animation instrument.
launch_app—terminate_runningrestarts a running app;envpasses launch environment variables (iOS only).open_url— opens a deeplink or universal link (simctl openurlon iOS,am start -a android.intent.action.VIEWon Android). No touch is involved, so on iOS it is by a wide margin the cheapest way to reach a screen — see the touch cost in Limitations.set_permission—{service, status: "grant" | "revoke" | "reset", app_id?}(simctl privacyon iOS,pm grant/pm revokeon Android). Granting before launch is the reliable way to keep a permission alert off the screen; dismissing one costs a touch, and on iOS some services only pick up the change after the app is terminated — the response says so rather than failing quietly.record_video/stop_recording— screen recording; defaults to$RNE_OUTPUT_DIRor~/Downloads. Android clips cap at 3 minutes (screenrecord limit).ping— no arguments, returnspong. It answers without touching Metro, the app or a device, which is exactly what makes it useful: it separates "the MCP server is not running" from "the app is not connected".
Configuration
Variable | Effect |
| Metro's port when it is not one of the standard ones. |
| Default directory for recordings and saved profiles (default |
| Comma-separated tool names to hide |
| Where the compiled OCR helper lives (default |
| Never install or start the on-device runner (iOS) or server (Android). Taps, swipes, typing and OS layout go through axe/adb exactly as before the driver existed. On Android it also force-stops a server left running by an earlier session |
Native driver
For faster, more reliable OS input the package ships prebuilt on-device drivers; there is nothing to build (scripts/build-drivers.sh is only for contributors). On first use it installs, per device:
Platform | What is installed |
iOS simulator | The runner app |
Android |
|
If a driver cannot be installed or started, input and layout fall back to axe/adb automatically, and the response names the path that ran. RNE_NATIVE_DRIVER=off opts out entirely.
While the Android server is up it holds UiAutomation, so other UiAutomation clients on that device (Appium, uiautomator dump) fail until it is stopped (adb shell am force-stop dev.rneye.server, or RNE_NATIVE_DRIVER=off).
Privacy
No telemetry. No accounts. No cloud. The only network traffic is to localhost (Metro) and local device drivers. This is enforced by an automated test that fails the build if any external URL appears in the source, and another that fails if a license/account/feedback tool ever shows up in the tool surface.
Limitations
The short version: dev builds only, and the JS path calls handlers rather than performing gestures.
Limitation | Detail |
Dev builds only | Requires Metro + Hermes; production builds are unsupported. |
Single debugger (RN < 0.85) | Hermes allows one CDP client at a time; RN 0.85+ supports multiple. |
Input path | A JS press calls a handler; it is not a gesture. Native views outside the React tree, system dialogs and permission alerts, Reanimated gestures that run on the UI thread, drag handles and swipeable rows, and momentum scrolling are not reproduced — those need |
Cold start | Logs/network before |
Store access | Reliable |
Limitation | Detail |
In-app interceptor | The network hooks live in the app, so they outlive the server. Connecting with a newer build replaces them; if an older build left hooks that cannot be removed, |
Screenshots | Inline screenshots are downscaled to 800px JPEG by default (pass |
Layout source |
|
Off-screen and hidden elements | Elements scrolled out of the viewport, and those on a screen that is mounted but not on display (an inactive tab, a stack screen below the top, |
Which screen is live | Taken from react-navigation / react-native-screens state ( |
Text input |
|
Touch cost on the iOS simulator without the native driver | Delivering a touch through axe to a foregrounded, busy RN app measured 17–25 s (worst 29.8 s) on iPhone 17 Pro / iOS 26.2 with axe 1.7.1, while HID typing, hardware buttons and screenshots against the same app in the same state stayed at 0.3–0.6 s. The cost is per touch event, not per process, so batching does not amortise it ( |
Error capture | In a dev build LogBox is the |
Route in | Found by walking the React tree for a react-navigation container (react-navigation 6/7, Expo Router). An app that navigates some other way can still expose a ref on |
Profiling |
|
OCR |
|
Development
npm install
npm run build # compile to build/
npm test # unit tests
npm run dev # run the server from sourcenpm run test:integrationruns the device tests. They need a booted iOS simulator (its udid inRNE_INTEGRATION_UDID) or an Android emulator (its serial inRNE_INTEGRATION_ANDROID_SERIAL).scripts/build-drivers.shrebuilds the native drivers undernative/(Xcode for iOS; JDK 17+ and the Android SDK for Android). Any change undernative/changes the driver version.scripts/pack-test.shpacks the tarball, installs it into a fresh project and checks that the drivers arrive intact and the server answers.
Pull requests are welcome: see CONTRIBUTING.md. Report security issues privately as described in SECURITY.md.
License
MIT for react-native-eye itself. The native drivers include third-party components under Apache-2.0, BSD and MIT licenses. Attributions are in NOTICE, the Apache-2.0 text is in native/LICENSE-APACHE, and per-driver details are in native/ios-runner/THIRD_PARTY.md and native/android-server/THIRD_PARTY.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
Control real Android and iOS devices with LLM agents — tap, swipe, type, automate flows.
Drive and test your React Native and Expo apps end-to-end on iOS and Android. Supports real devices, simulators, and emulators. Snapshots, taps, typing, swipes, screenshots, deep links, reloads, builds and runtime errors.
Drive real devices from your AI Coding tool. Embed a client SDK (Unity, Godot, Flutter, iOS/macOS, Android, React Native, Web) in your app, then capture screenshots, traverse the UI tree, inject taps and key events, and run automated test tasks on the physical device over a secure relay.
Build and run agents and automations across hundreds of apps using natural language.
Related MCP Servers
- AlicenseCqualityFmaintenanceProvides AI agents with visibility into React applications by exposing tools to inspect component state, props, and performance metrics. It enables debugging and state analysis for both web and React Native applications through the Model Context Protocol.457 npm1MIT
- AlicenseNot gradedqualityDmaintenanceEnables automation and monitoring of React Native apps by providing tools to tap, swipe, screenshot, inspect component state, profile renders, and mock network requests via MCP.6 npm7MIT
- AlicenseBqualityDmaintenanceEnables AI agents to inspect, log, and control React Native apps on Android emulators or iOS simulators, including UI tree, tap, scroll, and hot reload.22576 npm5MIT
- AlicenseBqualityBmaintenanceAn MCP server that gives AI agents hands-on control of a live React Native app, enabling UI taps, screenshots, UI/component trees, native logs, and JS/React profiling to debug, verify fixes, and diagnose performance issues.164 npmMIT