mcp-rn-devtools
by pablonortiz
README.md
# mcp-rn-devtools
[](https://github.com/pablonortiz/mcp-rn-devtools/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/mcp-rn-devtools)
[](https://www.npmjs.com/package/mcp-rn-devtools-sdk)
[](https://opensource.org/licenses/MIT)
An MCP server that gives Claude (or any MCP host) real-time access to your running React Native app — **including your Redux state and AsyncStorage, with zero app changes**. Console logs, errors, network, state, storage, action log, navigation, performance profiling, and more.
## Why this one
- **True zero-config state access.** A runtime agent injected over the Chrome DevTools Protocol walks the React fiber tree to discover your Redux store — no SDK, no middleware, no exposing the store on a global. Reading AsyncStorage works the same way (native module proxy). Install the server, ask Claude about your state.
- **Headless.** No desktop app to keep open, no toggle to remember. Works in fully autonomous agent workflows and CI.
- **Secrets redacted by default.** Tokens, passwords, auth headers, and JWT-shaped strings are masked server-side before anything reaches the LLM (`MCP_RN_NO_REDACT=1` to opt out).
- **Built for agent loops.** `clear_buffers` → reproduce → read. `wait_for_log` blocks until the app emits a matching log. `get_state_diff` shows exactly what changed between two moments.
## How It Works
```
Claude / MCP Host
│ MCP (stdio)
▼
mcp-rn-devtools (server)
├── CDP WebSocket ──► RN App (Hermes / Metro) ← zero config
│ └── runtime agent (injected): Redux discovery,
│ AsyncStorage, action log, navigation
└── SDK WebSocket ◄── mcp-rn-devtools-sdk ← optional enhancer
```
**Layer 1 — CDP + runtime agent (zero config):** Connects via Chrome DevTools Protocol through Metro's debugger proxy. Captures console logs, errors, warnings, and network requests, and injects a runtime agent that discovers Redux stores / React Navigation / React Query by walking the fiber tree, reads and writes AsyncStorage through the native module proxy, and records every dispatched action. Also provides JS evaluation, memory/CPU profiling, and source map resolution. **No app changes needed.**
**Layer 2 — SDK (optional enhancer):** Install `mcp-rn-devtools-sdk` for what the agent can't reach: Zustand/custom stores, MMKV, per-component render profiling, navigation timing, and a second capture channel that survives CDP drops.
## Installation
### With Claude Code
```bash
claude mcp add rn-devtools -- npx -y mcp-rn-devtools
```
Or add `.mcp.json` to your project root (shared with your team):
```json
{
"mcpServers": {
"rn-devtools": {
"command": "npx",
"args": ["-y", "mcp-rn-devtools"]
}
}
}
```
### With Claude Desktop
Add to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"rn-devtools": {
"command": "npx",
"args": ["-y", "mcp-rn-devtools"]
}
}
}
```
### Manual
```bash
npm install -g mcp-rn-devtools
mcp-rn-devtools --version
```
## Quick Start
1. **Start your React Native app** — Metro must be running, Hermes engine (default since RN 0.70).
2. **Add the MCP server** to your Claude config (see above).
3. **Ask Claude** about your app:
- *"What's in my Redux store right now?"*
- *"Read the AsyncStorage key persist:root"*
- *"Clear the buffers, I'll reproduce the bug — then show me what happened"*
- *"Dispatch auth/logout and show me the state diff"*
- *"Profile the CPU for 3 seconds and show me the hot functions"*
## Available Tools
> **Source legend:** **agent** = zero config, injected via CDP. **CDP** = zero config, protocol-level. **SDK** = requires `mcp-rn-devtools-sdk` in the app. Read-only tools are annotated with `readOnlyHint` so MCP hosts can auto-allow them.
### State & Actions
| Tool | Source | Description |
|------|--------|-------------|
| `get_app_state` | agent + SDK | Redux store state with dot-path access and depth control. Stores discovered automatically |
| `get_state_diff` | agent + SDK | What changed in the store since the last call (baseline → diff workflow) |
| `get_action_log` | agent + SDK | Dispatched actions with duration and changed slices — recorded automatically, no middleware |
| `dispatch_action` | agent | Dispatch a Redux action to reproduce states or trigger flows |
### Storage
| Tool | Source | Description |
|------|--------|-------------|
| `get_storage_keys` | agent + SDK | List AsyncStorage (zero-config) or MMKV (SDK) keys with search |
| `get_storage_value` | agent + SDK | Read a storage value — secrets redacted by default |
### Logging & Errors
| Tool | Source | Description |
|------|--------|-------------|
| `get_console_logs` | CDP + SDK | Console output with level filter and search; capped for the LLM by default, `full=true` for everything |
| `get_errors` | CDP + SDK | JS errors and exceptions with stack traces (5 frames by default, `full=true` for all) |
| `get_warnings` | CDP + SDK | LogBox warnings from console.warn, deduplicated across debugger reconnects |
| `wait_for_log` | CDP + SDK | Block until a log matching a pattern appears — synchronize with app activity |
### Network
| Tool | Source | Description |
|------|--------|-------------|
| `get_network_requests` | CDP + SDK | HTTP requests; `verbose` adds headers/bodies with secrets redacted, capped unless `full=true` |
| `get_failed_requests` | CDP + SDK | Requests with status >= 400 or network errors, response bodies capped unless `full=true` |
### Diagnostics
| Tool | Source | Description |
|------|--------|-------------|
| `health_check` | — | Verdict first — `READY` or `BLOCKED: cause → fix` — then version, debugger owner, stores, counts |
| `list_targets` | — | Targets on every Metro port (8081–8085) with who holds each one (→ this instance, ⊙ another instance, ✗ library runtime) |
| `select_target` | — | Pin a target by id (optionally with `metro_port`) or by `app` id prefix; takes it from the instance holding it; library runtimes (Reanimated) are refused |
| `clear_buffers` | — | Reset captured data before reproducing a scenario |
### Navigation
| Tool | Source | Description |
|------|--------|-------------|
| `get_navigation_state` | agent + SDK | Current route and stack (React Navigation), discovered automatically |
| `get_navigation_timing` | SDK | Screen transition timing with per-route summary |
### Memory & Performance
| Tool | Source | Description |
|------|--------|-------------|
| `get_memory_usage` | CDP | Current heap usage (used / total / percentage) |
| `take_heap_snapshot` | CDP | Heap snapshot summary — object count, top retainers by size |
| `get_cpu_profile` | CDP | CPU profile for N seconds — hot functions sorted by self time |
| `force_gc` | CDP | Trigger garbage collection, return before/after heap comparison |
| `get_render_profile` | SDK | Component render events — mount/update durations, slow renders |
### Advanced
| Tool | Source | Description |
|------|--------|-------------|
| `evaluate_js` | CDP | Execute JavaScript in the app: `global` and `globalThis` both work, `await_promise` settles Promises in-app |
| `resolve_source_location` | CDP | Resolve bundled line:column to original source via Metro source maps |
### Building on top of the core
The server exports its building blocks (`ConnectionManager`, `SDKBridgeServer`, the injected agent bridge, adb helpers) so tools can extend it **in the same process** — Hermes admits a single debugger, so extensions must share the CDP session rather than compete for it. Unrecognized SDK-channel messages are re-emitted as `sdk-message` events, and `sendToClient()` lets extensions answer.
The first product built this way is [**tapfix**](https://www.npmjs.com/package/tapfix): a live QA loop for React Native (mark issues on-device or from a cockpit web UI, and an embedded coding agent fixes them on the fly). Its MCP server is a superset of this one.
## Multiple sessions and multiple apps
Hermes admits **one** debugger per app instance, and every Claude Code session starts its own `mcp-rn-devtools` process. Ownership is settled **per app on a device**, not per machine:
- **Sessions on different apps coexist.** Two emulators, two apps, two sessions: each holds its own debugger. Instances register in `~/.mcp-rn-devtools/instances/` and ask each other to yield through a per-instance control endpoint — only the one holding *that* app is asked.
- **The debugger follows the session in use** for the *same* app. An instance attaches on its first tool call (`lazy`, the default); if a sibling holds that app, it yields and gets it back on its own next tool call. An instance kicked by a sibling does not fight back.
- **A session attaches to its own app.** The server reads the app id from the repo it runs in (`android/app/build.gradle` `applicationId`, the iOS bundle id, or Expo's `app.json`; `MCP_RN_APP` overrides) and finds that app across Metro ports 8081–8085 — a session in the wms repo attaches to wms even while picking runs on 8081. `select_target` also takes an `app` id.
- **No orphans.** The server exits when its MCP client goes away (stdin closes or the parent process dies).
- `health_check` shows the session app, who holds the debugger, the other instances and their apps, and who serves the SDK channel. `list_targets` marks targets held by other instances with ⊙.
Extensions that record continuously (tapfix) opt into `connectMode: 'eager'`. Instances older than 0.5 (no registry) are still asked to yield over the SDK port.
## Secret Redaction
Every tool that outputs app data (state, storage values, network headers/bodies, action payloads) masks secrets **server-side, before the data reaches the LLM**:
- Values under sensitive keys (`token`, `password`, `authorization`, `apiKey`, `session`, `cookie`, …) → `[REDACTED]`
- JWT-shaped strings and `Bearer …` tokens anywhere in text → masked
Redaction is a blocklist (defence in depth, not a guarantee) — audit what your app stores before pointing any LLM at it. Opt out with `MCP_RN_NO_REDACT=1`.
## SDK Setup (optional)
The runtime agent covers Redux, AsyncStorage, actions, and navigation with zero config. Install the SDK only if you need Zustand/custom stores, MMKV, render profiling, or navigation timing:
```bash
npm install mcp-rn-devtools-sdk --save-dev
```
```tsx
import { RNDevtoolsProvider } from 'mcp-rn-devtools-sdk';
export default function App() {
return (
<RNDevtoolsProvider>
<YourApp />
</RNDevtoolsProvider>
);
}
```
### With Zustand / custom stores
```tsx
const useAuthStore = create((set) => ({ /* ... */ }));
<RNDevtoolsProvider stateManagers={{ auth: useAuthStore }}>
<YourApp />
</RNDevtoolsProvider>
```
State snapshots are **pull-only**: they're serialized only when a tool asks, so the SDK adds zero overhead while idle.
### With MMKV
```tsx
import { MMKV } from 'react-native-mmkv';
const storage = new MMKV();
<RNDevtoolsProvider mmkv={storage}>
<YourApp />
</RNDevtoolsProvider>
```
### Per-Component Render Profiling
```tsx
import { RNDevtoolsProfiler } from 'mcp-rn-devtools-sdk';
<RNDevtoolsProfiler id="UserList">
<UserList />
</RNDevtoolsProfiler>
```
### Provider Props
| Prop | Type | Description |
|------|------|-------------|
| `navigationRef` | `RefObject` | React Navigation container ref for richer route tracking |
| `stateManagers` | `Record<string, StateStore>` | Zustand/custom stores for state inspection |
| `reduxMiddlewares` | `DevtoolsMiddleware[]` | Middlewares created via `createDevtoolsMiddleware()` |
| `asyncStorage` | `AsyncStorageLike` | AsyncStorage instance (also available zero-config via agent) |
| `mmkv` | `MMKVLike` | MMKV instance for storage reading |
| `port` | `number` | WebSocket port (default: `8098`) |
| `host` | `string` | Dev machine host — auto-detected from the bundle URL (`SourceCode.scriptURL`), works on emulators, physical devices, and `adb reverse` |
> The SDK automatically strips itself from production builds via `__DEV__` checks — zero overhead in release.
## Configuration
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `METRO_PORT` | `8081` | Metro bundler port (when it has no app, ports 8082–8085 are checked too) |
| `SDK_PORT` | `8098` | SDK WebSocket port — also marks which instance owns the debugger |
| `MCP_RN_CONNECT` | `lazy` | `lazy` attaches on the first tool call; `eager` attaches at startup |
| `MCP_RN_APP` | from the repo | App id prefix(es) this session works on, comma-separated (overrides the cwd detection) |
| `MCP_RN_STATE_DIR` | `~/.mcp-rn-devtools/instances` | Where instances register themselves for per-app ownership |
| `MCP_RN_NO_REDACT` | - | Disable secret redaction |
| `MCP_RN_NO_UPDATE_CHECK` | - | Skip the npm "newer version" lookup in `health_check` |
| `MCP_RN_DEBUG` | - | Enable debug logging |
## Architecture Notes
- **Target selection:** RN 0.76+ (Fusebox) no longer advertises `vm: 'Hermes'` — the server picks the main runtime by `reactNative.capabilities.prefersFuseboxFrontend` and skips secondary runtimes like Reanimated's. Legacy targets still work via the `vm` field.
- **Kick-and-poll:** CDP's `awaitPromise` can't resolve React Native's polyfilled Promises, so async in-app operations (AsyncStorage) fire a callback that writes to a result slot, which the server polls.
- **Clock skew:** log/error entries carry a server-clock `receivedAt` — device clocks can drift seconds from the host, which would break "wait for new logs" cuts.
- **Reconnection:** exponential backoff, agent re-injected automatically after every bundle reload. A pinned target that keeps failing is unpinned after 3 attempts.
- **Console replay:** `Runtime.enable` replays the runtime's console backlog on every reconnect; entries are deduplicated by (timestamp, message) so warnings do not inflate.
- **`global` in the evaluate scope:** Hermes exposes `globalThis` but not `global` to `Runtime.evaluate` (Metro only passes `global` to module factories). Injected scripts resolve the global object themselves and `evaluate_js` aliases `global` for the duration of the call.
- **Multi-Metro:** a known session app is searched across ports 8081–8085 regardless of what the configured port serves. Without a session app: when nothing listens on the configured port and exactly one other port has an app, the server switches to it; a running Metro without an app is left alone (the app is about to appear there), and with several candidates it asks you to `select_target`.
- **Target identity:** ownership is keyed by `reactNative.logicalDeviceId` (RN 0.73+: a stable hash of app + device, shared by the app's main and Reanimated runtimes), falling back to app id + device name.
- **Action log caveat:** the agent wraps `store.dispatch` at discovery time; components that captured a direct `dispatch` reference *before* discovery bypass the log (rare — discovery runs at connect).
## Compatibility
- **React Native:** 0.71+ (validated against 0.80 / bridgeless / Fusebox)
- **Engine:** Hermes (default since RN 0.70)
- **Platforms:** iOS, Android
- **Node.js:** 20+
- **MCP Hosts:** Claude Code, Claude Desktop, or any MCP-compatible client
## Development
```bash
mise install # node 22 + pnpm 9 from .mise.toml (pnpm 10+ breaks the build)
pnpm install
pnpm build # shared → server → sdk
pnpm test # vitest, includes fake Metro/Hermes integration tests
pnpm typecheck && pnpm lint
```
Release: bump `version` in the three `packages/*/package.json`, push, and publish a GitHub Release — `publish.yml` runs CI and `pnpm -r publish`.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues