@ceraph/react-native-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +16 -0
- package/dist/error-parser.d.ts +71 -0
- package/dist/error-parser.js +345 -0
- package/dist/expo-manager.d.ts +134 -0
- package/dist/expo-manager.js +561 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +442 -0
- package/dist/init.d.ts +8 -0
- package/dist/init.js +235 -0
- package/dist/prebuild-detector.d.ts +49 -0
- package/dist/prebuild-detector.js +215 -0
- package/dist/screen.d.ts +95 -0
- package/dist/screen.js +357 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
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
|
+
import { join } from "node:path";
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { RNManager } from "./expo-manager.js";
|
|
14
|
+
import { ScreenManager } from "./screen.js";
|
|
15
|
+
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).
|
|
22
|
+
const PROJECT_DIR = process.cwd();
|
|
23
|
+
const CACHE_DIR = join(PROJECT_DIR, ".rn-mcp-cache");
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Initialize managers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const rnManager = new RNManager(PROJECT_DIR);
|
|
28
|
+
const screenManager = new ScreenManager();
|
|
29
|
+
const prebuildDetector = new PrebuildDetector(PROJECT_DIR, CACHE_DIR);
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Create MCP server
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const server = new McpServer({
|
|
34
|
+
name: "react-native-mcp",
|
|
35
|
+
version: "0.1.0",
|
|
36
|
+
}, {
|
|
37
|
+
capabilities: {
|
|
38
|
+
tools: {},
|
|
39
|
+
},
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
server.tool("rn_build_ios", "Build and run the React Native app on an iOS device or simulator. " +
|
|
50
|
+
"Auto-detects Expo vs bare React Native and uses the correct command. " +
|
|
51
|
+
"Captures Xcode build output and returns structured error information. " +
|
|
52
|
+
"If clean is true, runs `npx expo prebuild --clean` first (Expo only).", {
|
|
53
|
+
clean: z
|
|
54
|
+
.boolean()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Run prebuild --clean before building (Expo only)"),
|
|
57
|
+
device: z
|
|
58
|
+
.string()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("Device UDID to target (e.g., 00008140-001958943A78801C)"),
|
|
61
|
+
}, async ({ clean, device }) => {
|
|
62
|
+
try {
|
|
63
|
+
const result = await rnManager.runBuild({ clean, device });
|
|
64
|
+
// On success, save a snapshot for future prebuild checks
|
|
65
|
+
if (result.success) {
|
|
66
|
+
await prebuildDetector.saveSnapshot().catch(() => {
|
|
67
|
+
// Non-critical; don't fail the build result over this
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: JSON.stringify(result, null, 2),
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
isError: !result.success,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return {
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: "text",
|
|
85
|
+
text: `rn_build_ios failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Tool: rn_start
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
server.tool("rn_start", "Start the Metro dev server. Auto-detects Expo vs bare React Native. " +
|
|
96
|
+
"Monitors console output for runtime errors and warnings.", {
|
|
97
|
+
port: z
|
|
98
|
+
.number()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe("Port for Metro bundler (default: 8081)"),
|
|
101
|
+
clear: z
|
|
102
|
+
.boolean()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Clear Metro bundler cache on start"),
|
|
105
|
+
}, async ({ port, clear }) => {
|
|
106
|
+
try {
|
|
107
|
+
const result = await rnManager.startMetro({ port, clear });
|
|
108
|
+
return {
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: "text",
|
|
112
|
+
text: JSON.stringify(result, null, 2),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
isError: !result.success,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{
|
|
122
|
+
type: "text",
|
|
123
|
+
text: `rn_start failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
isError: true,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Tool: rn_get_errors
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
server.tool("rn_get_errors", "Return all captured errors from build and runtime. " +
|
|
134
|
+
"Includes structured build errors (file, line, column, message), " +
|
|
135
|
+
"runtime JS errors (message, stack trace), and warnings.", {}, async () => {
|
|
136
|
+
try {
|
|
137
|
+
const errors = rnManager.getErrors();
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: "text",
|
|
142
|
+
text: JSON.stringify(errors, null, 2),
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: `rn_get_errors failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
isError: true,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Tool: rn_get_console
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
server.tool("rn_get_console", "Return recent console output from Metro dev server, " +
|
|
163
|
+
"optionally filtered by log level.", {
|
|
164
|
+
lines: z
|
|
165
|
+
.number()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe("Number of recent lines to return (default: 50)"),
|
|
168
|
+
level: z
|
|
169
|
+
.enum(["all", "error", "warn", "log"])
|
|
170
|
+
.optional()
|
|
171
|
+
.describe("Filter by log level (default: all)"),
|
|
172
|
+
}, async ({ lines, level }) => {
|
|
173
|
+
try {
|
|
174
|
+
const output = rnManager.getConsole({ lines, level });
|
|
175
|
+
if (output.length === 0) {
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: "text",
|
|
180
|
+
text: "No console output captured. Is Metro running? Use rn_start to launch it.",
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: output.join("\n"),
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
return {
|
|
196
|
+
content: [
|
|
197
|
+
{
|
|
198
|
+
type: "text",
|
|
199
|
+
text: `rn_get_console failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
isError: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Tool: rn_check_prebuild
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
server.tool("rn_check_prebuild", "Check if a clean Expo prebuild is needed (Expo projects only). " +
|
|
210
|
+
"Compares current package.json dependencies, app.json config, and " +
|
|
211
|
+
"ios/Podfile.lock against a cached snapshot from the last successful build.", {}, async () => {
|
|
212
|
+
try {
|
|
213
|
+
const isExpo = await rnManager.detectProjectType();
|
|
214
|
+
if (!isExpo) {
|
|
215
|
+
return {
|
|
216
|
+
content: [
|
|
217
|
+
{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: JSON.stringify({
|
|
220
|
+
needsClean: false,
|
|
221
|
+
reasons: ["Not an Expo project. Prebuild detection is Expo-only."],
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const result = await prebuildDetector.check();
|
|
228
|
+
return {
|
|
229
|
+
content: [
|
|
230
|
+
{
|
|
231
|
+
type: "text",
|
|
232
|
+
text: JSON.stringify(result, null, 2),
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
return {
|
|
239
|
+
content: [
|
|
240
|
+
{
|
|
241
|
+
type: "text",
|
|
242
|
+
text: `rn_check_prebuild failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
isError: true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Tool: screen_tap
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
server.tool("screen_tap", "Tap at specific coordinates on the iOS device screen via WebDriverAgent. " +
|
|
253
|
+
"Automatically corrects for pixel ratio mismatch when coordinates come " +
|
|
254
|
+
"from a screenshot (divides by device pixel ratio). " +
|
|
255
|
+
"Requires WebDriverAgent running on localhost:8100.", {
|
|
256
|
+
x: z.number().describe("X coordinate to tap"),
|
|
257
|
+
y: z.number().describe("Y coordinate to tap"),
|
|
258
|
+
coordinateSource: z
|
|
259
|
+
.enum(["screenshot", "device"])
|
|
260
|
+
.optional()
|
|
261
|
+
.describe("Source of coordinates: 'screenshot' (default) auto-corrects " +
|
|
262
|
+
"for pixel ratio; 'device' uses coordinates as-is"),
|
|
263
|
+
}, async ({ x, y, coordinateSource }) => {
|
|
264
|
+
try {
|
|
265
|
+
const available = await screenManager.isAvailable();
|
|
266
|
+
if (!available) {
|
|
267
|
+
return {
|
|
268
|
+
content: [
|
|
269
|
+
{
|
|
270
|
+
type: "text",
|
|
271
|
+
text: JSON.stringify({
|
|
272
|
+
success: false,
|
|
273
|
+
error: "WebDriverAgent is not reachable at localhost:8100. " +
|
|
274
|
+
"Ensure it is running on the device.",
|
|
275
|
+
tappedAt: { x, y },
|
|
276
|
+
pixelRatio: 0,
|
|
277
|
+
correction: "N/A",
|
|
278
|
+
}),
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
isError: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const fromScreenshot = (coordinateSource ?? "screenshot") === "screenshot";
|
|
285
|
+
const result = await screenManager.tap(x, y, fromScreenshot);
|
|
286
|
+
return {
|
|
287
|
+
content: [
|
|
288
|
+
{
|
|
289
|
+
type: "text",
|
|
290
|
+
text: JSON.stringify(result, null, 2),
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
isError: !result.success,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
return {
|
|
298
|
+
content: [
|
|
299
|
+
{
|
|
300
|
+
type: "text",
|
|
301
|
+
text: `screen_tap failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
isError: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Tool: screen_find_and_tap
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
server.tool("screen_find_and_tap", "Find a UI element on the iOS device screen by text, accessibility label, " +
|
|
312
|
+
"or element type, then tap its center. Uses the WebDriverAgent element tree. " +
|
|
313
|
+
"If no match is found, returns a summary of visible elements for debugging. " +
|
|
314
|
+
"Requires WebDriverAgent running on localhost:8100.", {
|
|
315
|
+
text: z
|
|
316
|
+
.string()
|
|
317
|
+
.optional()
|
|
318
|
+
.describe("Text content to search for (case-insensitive substring match)"),
|
|
319
|
+
accessibilityLabel: z
|
|
320
|
+
.string()
|
|
321
|
+
.optional()
|
|
322
|
+
.describe("Accessibility label to search for (case-insensitive substring match)"),
|
|
323
|
+
type: z
|
|
324
|
+
.string()
|
|
325
|
+
.optional()
|
|
326
|
+
.describe("Element type to filter by (e.g., XCUIElementTypeButton, XCUIElementTypeStaticText)"),
|
|
327
|
+
index: z
|
|
328
|
+
.number()
|
|
329
|
+
.optional()
|
|
330
|
+
.describe("If multiple matches, tap the nth match (0-indexed, default: 0)"),
|
|
331
|
+
}, async ({ text, accessibilityLabel, type, index }) => {
|
|
332
|
+
try {
|
|
333
|
+
const available = await screenManager.isAvailable();
|
|
334
|
+
if (!available) {
|
|
335
|
+
return {
|
|
336
|
+
content: [
|
|
337
|
+
{
|
|
338
|
+
type: "text",
|
|
339
|
+
text: JSON.stringify({
|
|
340
|
+
success: false,
|
|
341
|
+
error: "WebDriverAgent is not reachable at localhost:8100. " +
|
|
342
|
+
"Ensure it is running on the device.",
|
|
343
|
+
}),
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
isError: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (!text && !accessibilityLabel && !type) {
|
|
350
|
+
return {
|
|
351
|
+
content: [
|
|
352
|
+
{
|
|
353
|
+
type: "text",
|
|
354
|
+
text: JSON.stringify({
|
|
355
|
+
success: false,
|
|
356
|
+
error: "At least one of text, accessibilityLabel, or type must be provided.",
|
|
357
|
+
}),
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
isError: true,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
const result = await screenManager.findAndTap({
|
|
364
|
+
text,
|
|
365
|
+
accessibilityLabel,
|
|
366
|
+
type,
|
|
367
|
+
index,
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
content: [
|
|
371
|
+
{
|
|
372
|
+
type: "text",
|
|
373
|
+
text: JSON.stringify(result, null, 2),
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
isError: !result.success,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
return {
|
|
381
|
+
content: [
|
|
382
|
+
{
|
|
383
|
+
type: "text",
|
|
384
|
+
text: `screen_find_and_tap failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
isError: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Tool: rn_stop
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
server.tool("rn_stop", "Stop all managed React Native processes (Metro dev server and/or build process).", {}, async () => {
|
|
395
|
+
try {
|
|
396
|
+
const stopped = await rnManager.stopAll();
|
|
397
|
+
if (stopped.length === 0) {
|
|
398
|
+
return {
|
|
399
|
+
content: [
|
|
400
|
+
{
|
|
401
|
+
type: "text",
|
|
402
|
+
text: JSON.stringify({
|
|
403
|
+
stopped: [],
|
|
404
|
+
message: "No managed processes were running.",
|
|
405
|
+
}),
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
content: [
|
|
412
|
+
{
|
|
413
|
+
type: "text",
|
|
414
|
+
text: JSON.stringify({ stopped }, null, 2),
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
return {
|
|
421
|
+
content: [
|
|
422
|
+
{
|
|
423
|
+
type: "text",
|
|
424
|
+
text: `rn_stop failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
isError: true,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// Start the server
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
async function main() {
|
|
435
|
+
const transport = new StdioServerTransport();
|
|
436
|
+
await server.connect(transport);
|
|
437
|
+
console.error("[react-native-mcp] Server started on stdio transport");
|
|
438
|
+
}
|
|
439
|
+
main().catch((err) => {
|
|
440
|
+
console.error("[react-native-mcp] Fatal error:", err);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
});
|
package/dist/init.d.ts
ADDED
package/dist/init.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @ceraph/react-native-mcp init
|
|
4
|
+
*
|
|
5
|
+
* Sets up MCP configuration and the runtime error hook for the current project.
|
|
6
|
+
* Detects which MCP clients are in use and writes config for each one.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile, mkdir, access, chmod } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
const PROJECT_DIR = process.cwd();
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Hook script content
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const HOOK_SCRIPT = `#!/bin/bash
|
|
15
|
+
# rn-error-notify.sh — Injected by @ceraph/react-native-mcp init
|
|
16
|
+
# Reads .rn-errors.json and injects runtime errors into Claude's context.
|
|
17
|
+
|
|
18
|
+
ERROR_FILE="\$CLAUDE_PROJECT_DIR/mobile/.rn-errors.json"
|
|
19
|
+
|
|
20
|
+
# Also check project root if mobile/ doesn't exist
|
|
21
|
+
if [ ! -f "\$ERROR_FILE" ]; then
|
|
22
|
+
ERROR_FILE="\$CLAUDE_PROJECT_DIR/.rn-errors.json"
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
if [ ! -f "\$ERROR_FILE" ]; then
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
ERROR_COUNT=\$(jq -r '.errors | length' "\$ERROR_FILE" 2>/dev/null)
|
|
30
|
+
|
|
31
|
+
if [ "\$ERROR_COUNT" = "0" ] || [ -z "\$ERROR_COUNT" ]; then
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
echo "REACT NATIVE RUNTIME ERROR DETECTED:"
|
|
36
|
+
echo ""
|
|
37
|
+
jq -r '.errors[] | "Error: \\(.message)\\nStack: \\(.stack)\\nTime: \\(.timestamp)\\n---"' "\$ERROR_FILE" 2>/dev/null
|
|
38
|
+
echo ""
|
|
39
|
+
echo "Use rn_get_errors for full details. Fix the error and rebuild."
|
|
40
|
+
`;
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// MCP server entry (same for all JSON-based clients)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const MCP_ENTRY = {
|
|
45
|
+
"mobile-mcp": {
|
|
46
|
+
command: "npx",
|
|
47
|
+
args: ["-y", "@mobilenext/mobile-mcp@latest"],
|
|
48
|
+
},
|
|
49
|
+
"react-native-mcp": {
|
|
50
|
+
command: "npx",
|
|
51
|
+
args: ["-y", "@ceraph/react-native-mcp@latest"],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
async function fileExists(path) {
|
|
58
|
+
try {
|
|
59
|
+
await access(path);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function readJson(path) {
|
|
67
|
+
try {
|
|
68
|
+
const content = await readFile(path, "utf-8");
|
|
69
|
+
return JSON.parse(content);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function writeJson(path, data) {
|
|
76
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
77
|
+
await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
78
|
+
}
|
|
79
|
+
function log(msg) {
|
|
80
|
+
console.log(` ${msg}`);
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// MCP config writers
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
async function setupJsonMcp(configPath, label) {
|
|
86
|
+
const existing = (await readJson(configPath)) ?? {};
|
|
87
|
+
const servers = (existing.mcpServers ?? {});
|
|
88
|
+
if (servers["react-native-mcp"]) {
|
|
89
|
+
log(`${label}: already configured`);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
servers["mobile-mcp"] = MCP_ENTRY["mobile-mcp"];
|
|
93
|
+
servers["react-native-mcp"] = MCP_ENTRY["react-native-mcp"];
|
|
94
|
+
existing.mcpServers = servers;
|
|
95
|
+
await writeJson(configPath, existing);
|
|
96
|
+
log(`${label}: configured ✓`);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
async function setupCodexToml(configPath) {
|
|
100
|
+
let content = "";
|
|
101
|
+
try {
|
|
102
|
+
content = await readFile(configPath, "utf-8");
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// File doesn't exist, we'll create it
|
|
106
|
+
}
|
|
107
|
+
if (content.includes("react-native-mcp")) {
|
|
108
|
+
log("Codex: already configured");
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const tomlBlock = `
|
|
112
|
+
[mcp_servers.mobile-mcp]
|
|
113
|
+
command = "npx"
|
|
114
|
+
args = ["-y", "@mobilenext/mobile-mcp@latest"]
|
|
115
|
+
|
|
116
|
+
[mcp_servers.react-native-mcp]
|
|
117
|
+
command = "npx"
|
|
118
|
+
args = ["-y", "@ceraph/react-native-mcp@latest"]
|
|
119
|
+
`;
|
|
120
|
+
await mkdir(join(configPath, ".."), { recursive: true });
|
|
121
|
+
await writeFile(configPath, content + tomlBlock, "utf-8");
|
|
122
|
+
log("Codex: configured ✓");
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Hook setup
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
async function setupClaudeHook() {
|
|
129
|
+
// Write the hook script
|
|
130
|
+
const hooksDir = join(PROJECT_DIR, ".claude", "hooks");
|
|
131
|
+
const hookPath = join(hooksDir, "rn-error-notify.sh");
|
|
132
|
+
await mkdir(hooksDir, { recursive: true });
|
|
133
|
+
await writeFile(hookPath, HOOK_SCRIPT, "utf-8");
|
|
134
|
+
await chmod(hookPath, 0o755);
|
|
135
|
+
log("Hook script: .claude/hooks/rn-error-notify.sh ✓");
|
|
136
|
+
// Add FileChanged hook to settings.
|
|
137
|
+
// Check both settings.json and settings.local.json — if either already has
|
|
138
|
+
// the hook, we treat it as configured. This prevents duplicate hooks (which
|
|
139
|
+
// would fire twice per .rn-errors.json write) for projects where the hook
|
|
140
|
+
// was originally written to settings.local.json.
|
|
141
|
+
const sharedSettingsPath = join(PROJECT_DIR, ".claude", "settings.json");
|
|
142
|
+
const localSettingsPath = join(PROJECT_DIR, ".claude", "settings.local.json");
|
|
143
|
+
const isMatcher = (h) => h.matcher === ".rn-errors.json";
|
|
144
|
+
const fileChangedFrom = (s) => {
|
|
145
|
+
const hooks = (s?.hooks ?? {});
|
|
146
|
+
return (hooks.FileChanged ?? []);
|
|
147
|
+
};
|
|
148
|
+
const sharedSettings = await readJson(sharedSettingsPath);
|
|
149
|
+
const localSettings = await readJson(localSettingsPath);
|
|
150
|
+
if (fileChangedFrom(sharedSettings).some(isMatcher) ||
|
|
151
|
+
fileChangedFrom(localSettings).some(isMatcher)) {
|
|
152
|
+
log("Claude Code hook: already configured");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Write to settings.json (the shared default). settings.local.json is
|
|
156
|
+
// reserved for per-machine overrides; we don't write there.
|
|
157
|
+
const settings = sharedSettings ?? {};
|
|
158
|
+
const hooks = (settings.hooks ?? {});
|
|
159
|
+
const fileChangedHooks = (hooks.FileChanged ?? []);
|
|
160
|
+
fileChangedHooks.push({
|
|
161
|
+
matcher: ".rn-errors.json",
|
|
162
|
+
hooks: [
|
|
163
|
+
{
|
|
164
|
+
type: "command",
|
|
165
|
+
command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/rn-error-notify.sh',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
hooks.FileChanged = fileChangedHooks;
|
|
170
|
+
settings.hooks = hooks;
|
|
171
|
+
await writeJson(sharedSettingsPath, settings);
|
|
172
|
+
log("Claude Code hook: FileChanged → .rn-errors.json ✓");
|
|
173
|
+
}
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Gitignore
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
async function setupGitignore() {
|
|
178
|
+
const gitignorePath = join(PROJECT_DIR, ".gitignore");
|
|
179
|
+
let content = "";
|
|
180
|
+
try {
|
|
181
|
+
content = await readFile(gitignorePath, "utf-8");
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// No .gitignore yet
|
|
185
|
+
}
|
|
186
|
+
const entries = [".rn-errors.json", ".rn-mcp-cache/"];
|
|
187
|
+
const missing = entries.filter((e) => !content.includes(e));
|
|
188
|
+
if (missing.length === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const prefix = content === "" || content.endsWith("\n") ? "" : "\n";
|
|
192
|
+
const addition = prefix + missing.join("\n") + "\n";
|
|
193
|
+
await writeFile(gitignorePath, content + addition, "utf-8");
|
|
194
|
+
log(`.gitignore: added ${missing.join(", ")} ✓`);
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Main
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
async function main() {
|
|
200
|
+
console.log("\n@ceraph/react-native-mcp init\n");
|
|
201
|
+
// Detect and configure MCP clients
|
|
202
|
+
console.log("MCP configuration:");
|
|
203
|
+
// Claude Code
|
|
204
|
+
await setupJsonMcp(join(PROJECT_DIR, ".mcp.json"), "Claude Code");
|
|
205
|
+
// Cursor
|
|
206
|
+
await setupJsonMcp(join(PROJECT_DIR, ".cursor", "mcp.json"), "Cursor");
|
|
207
|
+
// Codex
|
|
208
|
+
await setupCodexToml(join(PROJECT_DIR, ".codex", "config.toml"));
|
|
209
|
+
// VS Code / Copilot
|
|
210
|
+
await setupJsonMcp(join(PROJECT_DIR, ".vscode", "mcp.json"), "VS Code");
|
|
211
|
+
// Windsurf (user-level config)
|
|
212
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
213
|
+
if (home) {
|
|
214
|
+
const windsurfPath = join(home, ".codeium", "windsurf", "mcp_config.json");
|
|
215
|
+
if (await fileExists(join(home, ".codeium", "windsurf"))) {
|
|
216
|
+
await setupJsonMcp(windsurfPath, "Windsurf");
|
|
217
|
+
}
|
|
218
|
+
// Antigravity (user-level config)
|
|
219
|
+
const antigravityPath = join(home, ".gemini", "antigravity", "mcp_config.json");
|
|
220
|
+
if (await fileExists(join(home, ".gemini", "antigravity"))) {
|
|
221
|
+
await setupJsonMcp(antigravityPath, "Antigravity");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Claude Code error hook
|
|
225
|
+
console.log("\nRuntime error hook:");
|
|
226
|
+
await setupClaudeHook();
|
|
227
|
+
// Gitignore
|
|
228
|
+
console.log("\nGitignore:");
|
|
229
|
+
await setupGitignore();
|
|
230
|
+
console.log("\nDone. Runtime errors will be automatically injected into Claude's context.\n");
|
|
231
|
+
}
|
|
232
|
+
main().catch((err) => {
|
|
233
|
+
console.error("Init failed:", err);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|