Skip to content

Commit 5aba55f

Browse files
kbrandwijkclaudejoshuayoes
authored
feat: add MCP server for Claude Code integration (#1598)
## Please verify the following: - [x] `yarn build-and-test:local` passes - [x] I have added tests for any new features, if relevant - [x] `README.md` (or relevant documentation) has been updated with your changes ## Describe your PR Adds a built-in [MCP](https://modelcontextprotocol.io/) server to Reactotron, allowing AI coding assistants like [Claude Code](https://claude.ai/claude-code) to read debug events and send commands to connected React Native / React apps. ### What it does **New package: `reactotron-mcp`** (`lib/reactotron-mcp/`) A self-contained MCP server that receives the `reactotron-core-server` instance and exposes its data as MCP resources and tools over HTTP. **Resources** (read-only debug data): - Timeline — last 500 events (logs, state changes, network, benchmarks, custom commands) - App state — latest cached Redux/MST snapshot - Network log — captured HTTP request/response pairs - Connected apps — with platform, version, and clientId - Benchmarks — performance benchmark results - State subscriptions — values at subscribed state paths - AsyncStorage — mutations (setItem, removeItem, etc.) **Tools** (interact with running app): - `dispatch_action` — dispatch Redux actions with confirmation polling - `request_state` — request fresh state snapshot (1.5s timeout) - `swap_state` — hot-swap entire state tree - `send_custom_command` — trigger registered custom commands - `list_custom_commands` — discover available custom commands - `show_overlay` — image overlay with local file→base64 conversion and dimension extraction - `subscribe_state` / `unsubscribe_state` — watch state paths for changes - `clear_timeline` — clear MCP event buffer ### Architecture ``` React Native app | WebSocket (port 9090, unchanged) v Reactotron Desktop ├── relay server (reactotron-core-server, unchanged) └── MCP server (reactotron-mcp, HTTP on configurable port) ↑ Claude Code ``` - **No changes to the React Native app or relay protocol** - MCP server reads directly from the server instance's connections and event emitter - Built with tsup (Node.js server, not a React Native lib) - Bundles `@modelcontextprotocol/sdk` (its CJS exports are broken) - Stateless per-request HTTP transport (required by MCP SDK) ### Desktop app changes - "MCP" toggle button in the footer bar with green status indicator - MCP port configurable via electron-store (default: 4567, localhost only) - `reactotron-mcp` is NOT whitelisted in electron-webpack — loaded via Node.js at runtime ### Multi-app support When multiple apps are connected, resources include `_meta` hints guiding Claude to ask the user which app they're working on. Single-app connections are auto-selected. ### Setup ```bash claude mcp add --transport http reactotron http://localhost:4567/mcp ``` ### Tests 20 integration tests covering resources, tools, multi-app handling, and edge cases. ### Documentation Added `docs/mcp.md` with getting started guide, feature overview, and architecture docs. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Joshua Yoes <37849890+joshuayoes@users.noreply.github.com>
1 parent 40b15e3 commit 5aba55f

27 files changed

Lines changed: 3936 additions & 21 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
diff --git a/ios/LocalizationModule.swift b/ios/LocalizationModule.swift
2+
index 17c9cb78a927db705f331827f4f48e894317c42f..26bbc58d567a8a69df1fdf25413d04e2fc433480 100644
3+
--- a/ios/LocalizationModule.swift
4+
+++ b/ios/LocalizationModule.swift
5+
@@ -123,6 +123,8 @@ public class LocalizationModule: Module {
6+
return "roc"
7+
case .iso8601:
8+
return "iso8601"
9+
+ @unknown default:
10+
+ return calendar.identifier.debugDescription
11+
}
12+
}
13+

apps/example-app/app/devtools/ReactotronConfig.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,58 @@ reactotron.onCustomCommand({
117117
},
118118
})
119119

120+
reactotron.onCustomCommand<[{ name: "message"; type: ArgType.String }]>({
121+
command: "showAlert",
122+
title: "Show Alert",
123+
description: "Displays an alert dialog on the device with a custom message",
124+
args: [{ name: "message", type: ArgType.String }],
125+
handler: (args) => {
126+
const { message } = args ?? {}
127+
const { Alert } = require("react-native")
128+
Alert.alert("Reactotron", message || "Hello from Reactotron!")
129+
Reactotron.log(`Alert shown: ${message || "Hello from Reactotron!"}`)
130+
},
131+
})
132+
133+
reactotron.onCustomCommand<[{ name: "key"; type: ArgType.String }, { name: "value"; type: ArgType.String }]>({
134+
command: "setAsyncStorage",
135+
title: "Set AsyncStorage Value",
136+
description: "Sets a key/value pair in AsyncStorage",
137+
args: [
138+
{ name: "key", type: ArgType.String },
139+
{ name: "value", type: ArgType.String },
140+
],
141+
handler: async (args) => {
142+
const { key, value } = args ?? {}
143+
if (key && value) {
144+
await AsyncStorage.setItem(key, value)
145+
Reactotron.log(`AsyncStorage set: ${key} = ${value}`)
146+
} else {
147+
Reactotron.log("Missing key or value")
148+
}
149+
},
150+
})
151+
152+
reactotron.onCustomCommand<[{ name: "key"; type: ArgType.String }]>({
153+
command: "getAsyncStorage",
154+
title: "Get AsyncStorage Value",
155+
description: "Reads a value from AsyncStorage and logs it to the timeline",
156+
args: [{ name: "key", type: ArgType.String }],
157+
handler: async (args) => {
158+
const { key } = args ?? {}
159+
if (key) {
160+
const value = await AsyncStorage.getItem(key)
161+
Reactotron.display({
162+
name: "AsyncStorage",
163+
preview: `${key} = ${value}`,
164+
value: { key, value },
165+
})
166+
} else {
167+
Reactotron.log("Missing key")
168+
}
169+
},
170+
})
171+
120172
/**
121173
* We're going to add `console.tron` to the Reactotron object.
122174
* Now, anywhere in our app in development, we can use Reactotron like so:

apps/example-app/app/screens/LoggingScreen.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,44 @@ export const LoggingScreen: React.FC<LoggingScreenProps> = function LoggingScree
105105
}}
106106
/>
107107
</View>
108+
<View style={$topContainer}>
109+
<Text style={$text} text="Stress Test" />
110+
</View>
111+
<View style={{ marginTop: spacing.lg }}>
112+
<Button
113+
text="Spam 100 logs"
114+
textStyle={$darkText}
115+
style={$button}
116+
onPress={() => {
117+
for (let i = 0; i < 100; i++) {
118+
console.log(`Spam log #${i}`, {
119+
index: i,
120+
timestamp: Date.now(),
121+
data: "x".repeat(500),
122+
nested: { a: { b: { c: { d: `deep-value-${i}` } } } },
123+
})
124+
}
125+
}}
126+
/>
127+
<Button
128+
text="Log huge object"
129+
textStyle={$darkText}
130+
style={$button}
131+
onPress={() => {
132+
const bigState: Record<string, any> = {}
133+
for (let i = 0; i < 200; i++) {
134+
bigState[`key_${i}`] = {
135+
id: i,
136+
name: `Item ${i}`,
137+
description: `This is a description for item ${i} with some padding ${"x".repeat(200)}`,
138+
tags: Array.from({ length: 10 }, (_, j) => `tag-${i}-${j}`),
139+
metadata: { created: new Date().toISOString(), updated: new Date().toISOString() },
140+
}
141+
}
142+
console.log("Huge state dump:", bigState)
143+
}}
144+
/>
145+
</View>
108146
<View style={$topContainer}>
109147
<Text style={$text} tx="loggingsScreen.subtitle" />
110148
</View>

apps/example-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"expo-font": "~12.0.10",
4747
"expo-linear-gradient": "~13.0.2",
4848
"expo-linking": "~6.3.1",
49-
"expo-localization": "~15.0.3",
49+
"expo-localization": "patch:expo-localization@npm%3A15.0.3#~/.yarn/patches/expo-localization-npm-15.0.3-6e534a2836.patch",
5050
"expo-splash-screen": "~0.27.7",
5151
"expo-status-bar": "~1.12.1",
5252
"i18n-js": "3.9.2",

apps/reactotron-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"reactotron-core-contract": "workspace:*",
6969
"reactotron-core-server": "workspace:*",
7070
"reactotron-core-ui": "workspace:*",
71+
"reactotron-mcp": "workspace:*",
7172
"source-map-support": "^0.5.21",
7273
"styled-components": "^6.1.0",
7374
"v8-compile-cache": "^2.4.0"

apps/reactotron-app/src/renderer/components/Footer/Footer.story.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ storiesOf("components/Footer", module)
6363
isOpen={false}
6464
setIsOpen={() => {}}
6565
onChangeConnection={() => {}}
66+
mcpStatus="stopped"
67+
mcpPort={null}
68+
onToggleMcp={() => {}}
6669
/>
6770
))
6871
.add("Collpased w/ connections", () => (
@@ -73,6 +76,9 @@ storiesOf("components/Footer", module)
7376
isOpen={false}
7477
setIsOpen={() => {}}
7578
onChangeConnection={() => {}}
79+
mcpStatus="stopped"
80+
mcpPort={null}
81+
onToggleMcp={() => {}}
7682
/>
7783
))
7884
.add("Expanded", () => (
@@ -83,6 +89,9 @@ storiesOf("components/Footer", module)
8389
isOpen
8490
setIsOpen={() => {}}
8591
onChangeConnection={() => {}}
92+
mcpStatus="stopped"
93+
mcpPort={null}
94+
onToggleMcp={() => {}}
8695
/>
8796
))
8897
.add("Expanded w/ connections", () => (
@@ -93,6 +102,9 @@ storiesOf("components/Footer", module)
93102
isOpen
94103
setIsOpen={() => {}}
95104
onChangeConnection={() => {}}
105+
mcpStatus="started"
106+
mcpPort={4567}
107+
onToggleMcp={() => {}}
96108
/>
97109
))
98110
.add("Expanded w/ lots connections", () => (
@@ -103,5 +115,8 @@ storiesOf("components/Footer", module)
103115
isOpen
104116
setIsOpen={() => {}}
105117
onChangeConnection={() => {}}
118+
mcpStatus="started"
119+
mcpPort={4567}
120+
onToggleMcp={() => {}}
106121
/>
107122
))

apps/reactotron-app/src/renderer/components/Footer/Stateless.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getConnectionName,
1010
} from "../../util/connectionHelpers"
1111
import { Connection, ServerStatus } from "../../contexts/Standalone/useStandalone"
12+
import { McpStatus } from "../../contexts/Standalone"
1213
import ConnectionSelector from "../ConnectionSelector"
1314

1415
const Container = styled.div`
@@ -49,6 +50,34 @@ const ExpandContainer = styled.div`
4950
cursor: pointer;
5051
`
5152

53+
interface McpButtonProps {
54+
$active: boolean
55+
}
56+
57+
const McpButton = styled.div.attrs(() => ({}))<McpButtonProps>`
58+
display: flex;
59+
align-items: center;
60+
gap: 6px;
61+
padding: 2px 8px;
62+
border-radius: 3px;
63+
cursor: pointer;
64+
font-size: 11px;
65+
user-select: none;
66+
background-color: ${(props) => props.$active ? "rgba(80, 200, 120, 0.15)" : "transparent"};
67+
border: 1px solid ${(props) => props.$active ? "rgba(80, 200, 120, 0.4)" : props.theme.chromeLine};
68+
color: ${(props) => props.$active ? "#50c878" : props.theme.foregroundDark};
69+
&:hover {
70+
background-color: ${(props) => props.$active ? "rgba(80, 200, 120, 0.25)" : "rgba(255,255,255,0.05)"};
71+
}
72+
`
73+
74+
const McpDot = styled.div<McpButtonProps>`
75+
width: 6px;
76+
height: 6px;
77+
border-radius: 50%;
78+
background-color: ${(props) => props.$active ? "#50c878" : props.theme.foregroundDark};
79+
`
80+
5281
function renderExpanded(
5382
serverStatus: ServerStatus,
5483
connections: Connection[],
@@ -105,6 +134,9 @@ interface Props {
105134
isOpen: boolean
106135
setIsOpen: (isOpen: boolean) => void
107136
onChangeConnection: (clientId: string | null) => void
137+
mcpStatus: McpStatus
138+
mcpPort: number | null
139+
onToggleMcp: () => void
108140
}
109141

110142
function Header({
@@ -114,13 +146,24 @@ function Header({
114146
isOpen,
115147
setIsOpen,
116148
onChangeConnection,
149+
mcpStatus,
150+
mcpPort,
151+
onToggleMcp,
117152
}: Props) {
118153
const renderMethod = isOpen ? renderExpanded : renderCollapsed
119154

120155
return (
121156
<Container>
122157
<ContentContainer onClick={() => !isOpen && setIsOpen(true)} $isOpen={isOpen}>
123158
{renderMethod(serverStatus, connections, selectedConnection, onChangeConnection)}
159+
<McpButton
160+
$active={mcpStatus === "started"}
161+
onClick={(e) => { e.stopPropagation(); onToggleMcp() }}
162+
title={mcpStatus === "started" ? `MCP running on port ${mcpPort}` : "Start MCP server"}
163+
>
164+
<McpDot $active={mcpStatus === "started"} />
165+
{mcpStatus === "started" ? `MCP :${mcpPort}` : "MCP"}
166+
</McpButton>
124167
<ExpandContainer onClick={() => setIsOpen(!isOpen)}>
125168
<ExpandIcon size={18} />
126169
</ExpandContainer>

apps/reactotron-app/src/renderer/components/Footer/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import StandaloneContext from "../../contexts/Standalone"
55
import Footer from "./Stateless"
66

77
export default function ConnectedFooter() {
8-
const { serverStatus, connections, selectedConnection, selectConnection } =
8+
const { serverStatus, connections, selectedConnection, selectConnection, mcpStatus, mcpPort, toggleMcp } =
99
useContext(StandaloneContext)
1010
const [isOpen, setIsOpen] = useState(false)
1111

@@ -17,6 +17,9 @@ export default function ConnectedFooter() {
1717
onChangeConnection={selectConnection}
1818
isOpen={isOpen}
1919
setIsOpen={setIsOpen}
20+
mcpStatus={mcpStatus}
21+
mcpPort={mcpPort}
22+
onToggleMcp={toggleMcp}
2023
/>
2124
)
2225
}

apps/reactotron-app/src/renderer/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Store from "electron-store"
33
type StoreType = {
44
serverPort: number
55
commandHistory: number
6+
mcpPort: number
67
}
78

89
const config = new Store<StoreType>({
@@ -15,6 +16,10 @@ const config = new Store<StoreType>({
1516
type: "number",
1617
default: 500,
1718
},
19+
mcpPort: {
20+
type: "number",
21+
default: 4567,
22+
},
1823
},
1924
})
2025

@@ -25,5 +30,8 @@ if (!config.has("serverPort")) {
2530
if (!config.has("commandHistory")) {
2631
config.set("commandHistory", 500)
2732
}
33+
if (!config.has("mcpPort")) {
34+
config.set("mcpPort", 4567)
35+
}
2836

2937
export default config

apps/reactotron-app/src/renderer/contexts/Standalone/index.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
1-
import React, { useRef, useEffect, useCallback } from "react"
1+
import React, { useRef, useEffect, useCallback, useState } from "react"
22
import Server, { createServer } from "reactotron-core-server"
3+
import { createMcpServer, type ReactotronMcpServer } from "reactotron-mcp"
34

45
import ReactotronBrain from "../../ReactotronBrain"
56
import config from "../../config"
67

78
import useStandalone, { Connection, ServerStatus } from "./useStandalone"
89

10+
export type McpStatus = "stopped" | "started" | "error"
11+
912
// TODO: Move up to better places like core somewhere!
1013
interface Context {
1114
serverStatus: ServerStatus
1215
connections: Connection[]
1316
selectedConnection: Connection
1417
selectConnection: (clientId: string) => void
18+
mcpStatus: McpStatus
19+
mcpPort: number | null
20+
toggleMcp: () => void
1521
}
1622

1723
const StandaloneContext = React.createContext<Context>({
1824
serverStatus: "stopped",
1925
connections: [],
2026
selectedConnection: null,
2127
selectConnection: null,
28+
mcpStatus: "stopped",
29+
mcpPort: null,
30+
toggleMcp: () => {},
2231
})
2332

2433
const Provider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -66,6 +75,44 @@ const Provider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
6675
portUnavailable,
6776
])
6877

78+
const mcpServerRef = useRef<ReactotronMcpServer>(null)
79+
const [mcpStatus, setMcpStatus] = useState<McpStatus>("stopped")
80+
const [mcpPort, setMcpPort] = useState<number | null>(null)
81+
82+
// Clean up MCP server on unmount
83+
useEffect(() => {
84+
return () => {
85+
if (mcpServerRef.current) {
86+
mcpServerRef.current.stop()
87+
mcpServerRef.current = null
88+
}
89+
}
90+
}, [])
91+
92+
const toggleMcp = useCallback(() => {
93+
if (!reactotronServer.current) return
94+
95+
if (mcpStatus === "started") {
96+
if (mcpServerRef.current) {
97+
mcpServerRef.current.stop()
98+
mcpServerRef.current = null
99+
}
100+
setMcpStatus("stopped")
101+
setMcpPort(null)
102+
} else {
103+
const port = config.get("mcpPort") as number
104+
const mcp = createMcpServer(reactotronServer.current)
105+
mcp.start(port).then(() => {
106+
mcpServerRef.current = mcp
107+
setMcpStatus("started")
108+
setMcpPort(port)
109+
}).catch(() => {
110+
setMcpStatus("error")
111+
setMcpPort(null)
112+
})
113+
}
114+
}, [mcpStatus])
115+
69116
const sendCommand = useCallback(
70117
(type: string, payload: any, clientId?: string) => {
71118
// TODO: Do better then just throwing these away...
@@ -83,6 +130,9 @@ const Provider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
83130
connections,
84131
selectedConnection,
85132
selectConnection,
133+
mcpStatus,
134+
mcpPort,
135+
toggleMcp,
86136
}}
87137
>
88138
<ReactotronBrain

0 commit comments

Comments
 (0)