@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/screen.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebDriverAgent client with pixel ratio correction.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the WDA HTTP server running on localhost:8100 to perform
|
|
5
|
+
* taps and element lookups on a connected iOS device.
|
|
6
|
+
*/
|
|
7
|
+
const WDA_BASE_URL = "http://localhost:8100";
|
|
8
|
+
const WDA_TIMEOUT_MS = 5000;
|
|
9
|
+
/**
|
|
10
|
+
* Fetch wrapper with timeout for WDA requests.
|
|
11
|
+
*/
|
|
12
|
+
async function wdaFetch(path, options = {}) {
|
|
13
|
+
const url = `${WDA_BASE_URL}${path}`;
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const timeout = setTimeout(() => controller.abort(), WDA_TIMEOUT_MS);
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
...options,
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
headers: {
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
...options.headers,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
return response;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class ScreenManager {
|
|
32
|
+
sessionId = null;
|
|
33
|
+
pixelRatio = null;
|
|
34
|
+
/**
|
|
35
|
+
* Check whether WebDriverAgent is reachable.
|
|
36
|
+
*/
|
|
37
|
+
async isAvailable() {
|
|
38
|
+
try {
|
|
39
|
+
const res = await wdaFetch("/status");
|
|
40
|
+
return res.ok;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get or create a WDA session. Sessions are cached and revalidated.
|
|
48
|
+
*/
|
|
49
|
+
async ensureSession() {
|
|
50
|
+
// Try to reuse cached session
|
|
51
|
+
if (this.sessionId) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await wdaFetch(`/session/${this.sessionId}`);
|
|
54
|
+
if (res.ok)
|
|
55
|
+
return this.sessionId;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Session is stale, create a new one
|
|
59
|
+
}
|
|
60
|
+
this.sessionId = null;
|
|
61
|
+
}
|
|
62
|
+
// Try to find an existing session from /status
|
|
63
|
+
try {
|
|
64
|
+
const statusRes = await wdaFetch("/status");
|
|
65
|
+
if (statusRes.ok) {
|
|
66
|
+
const statusData = (await statusRes.json());
|
|
67
|
+
const existingId = statusData.sessionId || statusData.value?.sessionId;
|
|
68
|
+
if (existingId) {
|
|
69
|
+
this.sessionId = existingId;
|
|
70
|
+
return existingId;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Fall through to session creation
|
|
76
|
+
}
|
|
77
|
+
// Create a new session
|
|
78
|
+
const res = await wdaFetch("/session", {
|
|
79
|
+
method: "POST",
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
capabilities: {
|
|
82
|
+
alwaysMatch: {},
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
const text = await res.text().catch(() => "unknown error");
|
|
88
|
+
throw new Error(`Failed to create WDA session: ${res.status} ${text}`);
|
|
89
|
+
}
|
|
90
|
+
const data = (await res.json());
|
|
91
|
+
const sessionId = data.sessionId || data.value?.sessionId;
|
|
92
|
+
if (!sessionId) {
|
|
93
|
+
throw new Error("WDA session creation succeeded but no sessionId returned");
|
|
94
|
+
}
|
|
95
|
+
this.sessionId = sessionId;
|
|
96
|
+
return sessionId;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Determine the pixel ratio between screenshot coordinates and
|
|
100
|
+
* device logical coordinates.
|
|
101
|
+
*
|
|
102
|
+
* Screenshot images are in physical pixels; WDA taps use logical points.
|
|
103
|
+
* The ratio is typically 2 (for @2x Retina) or 3 (for @3x).
|
|
104
|
+
*/
|
|
105
|
+
async getPixelRatio() {
|
|
106
|
+
if (this.pixelRatio)
|
|
107
|
+
return this.pixelRatio;
|
|
108
|
+
const sessionId = await this.ensureSession();
|
|
109
|
+
// Get the logical window size from WDA
|
|
110
|
+
const windowRes = await wdaFetch(`/session/${sessionId}/window/size`);
|
|
111
|
+
if (!windowRes.ok) {
|
|
112
|
+
throw new Error(`Failed to get window size: ${windowRes.status}`);
|
|
113
|
+
}
|
|
114
|
+
const windowData = (await windowRes.json());
|
|
115
|
+
const logicalWidth = windowData.value.width;
|
|
116
|
+
// Get a screenshot to measure actual pixel dimensions
|
|
117
|
+
const screenshotRes = await wdaFetch(`/session/${sessionId}/screenshot`);
|
|
118
|
+
if (!screenshotRes.ok) {
|
|
119
|
+
// Fallback: assume 3x for modern iPhones
|
|
120
|
+
this.pixelRatio = 3;
|
|
121
|
+
return this.pixelRatio;
|
|
122
|
+
}
|
|
123
|
+
const screenshotData = (await screenshotRes.json());
|
|
124
|
+
const base64 = screenshotData.value;
|
|
125
|
+
// Decode the PNG header to get image width.
|
|
126
|
+
// PNG stores width as a big-endian 32-bit int at byte offset 16.
|
|
127
|
+
const pngBuffer = Buffer.from(base64, "base64");
|
|
128
|
+
if (pngBuffer.length < 24) {
|
|
129
|
+
// Not enough data to read PNG header; fallback
|
|
130
|
+
this.pixelRatio = 3;
|
|
131
|
+
return this.pixelRatio;
|
|
132
|
+
}
|
|
133
|
+
const imageWidth = pngBuffer.readUInt32BE(16);
|
|
134
|
+
this.pixelRatio = Math.round(imageWidth / logicalWidth);
|
|
135
|
+
// Sanity check
|
|
136
|
+
if (this.pixelRatio < 1 || this.pixelRatio > 4) {
|
|
137
|
+
this.pixelRatio = 3; // safe default for modern iPhones
|
|
138
|
+
}
|
|
139
|
+
return this.pixelRatio;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Tap at the given coordinates.
|
|
143
|
+
*
|
|
144
|
+
* When `fromScreenshot` is true (the default), the coordinates are
|
|
145
|
+
* assumed to come from a screenshot image and are divided by the
|
|
146
|
+
* pixel ratio to convert to logical device points.
|
|
147
|
+
*/
|
|
148
|
+
async tap(x, y, fromScreenshot = true) {
|
|
149
|
+
try {
|
|
150
|
+
const sessionId = await this.ensureSession();
|
|
151
|
+
const ratio = await this.getPixelRatio();
|
|
152
|
+
const correctedX = fromScreenshot ? x / ratio : x;
|
|
153
|
+
const correctedY = fromScreenshot ? y / ratio : y;
|
|
154
|
+
const res = await wdaFetch(`/session/${sessionId}/wda/tap/0`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: JSON.stringify({ x: correctedX, y: correctedY }),
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const text = await res.text().catch(() => "unknown");
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
tappedAt: { x: correctedX, y: correctedY },
|
|
163
|
+
pixelRatio: ratio,
|
|
164
|
+
correction: fromScreenshot
|
|
165
|
+
? `Divided by ${ratio}x: (${x},${y}) -> (${correctedX.toFixed(1)},${correctedY.toFixed(1)})`
|
|
166
|
+
: "No correction (device coordinates)",
|
|
167
|
+
error: `WDA tap failed: ${res.status} ${text}`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
success: true,
|
|
172
|
+
tappedAt: { x: correctedX, y: correctedY },
|
|
173
|
+
pixelRatio: ratio,
|
|
174
|
+
correction: fromScreenshot
|
|
175
|
+
? `Divided by ${ratio}x: (${x},${y}) -> (${correctedX.toFixed(1)},${correctedY.toFixed(1)})`
|
|
176
|
+
: "No correction (device coordinates)",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
tappedAt: { x, y },
|
|
183
|
+
pixelRatio: this.pixelRatio ?? 0,
|
|
184
|
+
correction: "Failed before correction could be applied",
|
|
185
|
+
error: err instanceof Error ? err.message : "Unknown error during tap",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Find an element in the UI tree and tap its center.
|
|
191
|
+
*/
|
|
192
|
+
async findAndTap(query) {
|
|
193
|
+
try {
|
|
194
|
+
const sessionId = await this.ensureSession();
|
|
195
|
+
// Fetch the full element source tree
|
|
196
|
+
const sourceRes = await wdaFetch(`/session/${sessionId}/source?format=json`);
|
|
197
|
+
if (!sourceRes.ok) {
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
error: `Failed to get element tree: ${sourceRes.status}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const sourceData = (await sourceRes.json());
|
|
204
|
+
const root = sourceData.value;
|
|
205
|
+
// Find matching elements
|
|
206
|
+
const matches = this.searchElements(root, query);
|
|
207
|
+
if (matches.length === 0) {
|
|
208
|
+
// Collect a summary of visible elements for debugging
|
|
209
|
+
const visible = this.collectVisibleElements(root, 50);
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
availableElements: visible,
|
|
213
|
+
error: `No element found matching query: ${JSON.stringify(query)}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// Select the element (by index or first match)
|
|
217
|
+
const index = query.index ?? 0;
|
|
218
|
+
if (index >= matches.length) {
|
|
219
|
+
return {
|
|
220
|
+
success: false,
|
|
221
|
+
error: `Index ${index} out of range. Found ${matches.length} matching element(s).`,
|
|
222
|
+
availableElements: matches.map((m) => ({
|
|
223
|
+
type: m.type ?? "unknown",
|
|
224
|
+
label: m.label ?? "",
|
|
225
|
+
text: String(m.value ?? m.label ?? ""),
|
|
226
|
+
})),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const element = matches[index];
|
|
230
|
+
const rect = element.rect;
|
|
231
|
+
if (!rect) {
|
|
232
|
+
return {
|
|
233
|
+
success: false,
|
|
234
|
+
error: "Matched element has no bounds/rect information.",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
// Calculate center point (already in device/logical coordinates)
|
|
238
|
+
const centerX = rect.x + rect.width / 2;
|
|
239
|
+
const centerY = rect.y + rect.height / 2;
|
|
240
|
+
// Tap at center -- these are already logical coordinates, no correction
|
|
241
|
+
const tapRes = await wdaFetch(`/session/${sessionId}/wda/tap/0`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: JSON.stringify({ x: centerX, y: centerY }),
|
|
244
|
+
});
|
|
245
|
+
if (!tapRes.ok) {
|
|
246
|
+
const text = await tapRes.text().catch(() => "unknown");
|
|
247
|
+
return {
|
|
248
|
+
success: false,
|
|
249
|
+
error: `WDA tap failed: ${tapRes.status} ${text}`,
|
|
250
|
+
element: {
|
|
251
|
+
type: element.type ?? "unknown",
|
|
252
|
+
label: element.label ?? element.name ?? "",
|
|
253
|
+
bounds: rect,
|
|
254
|
+
},
|
|
255
|
+
tappedAt: { x: centerX, y: centerY },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
success: true,
|
|
260
|
+
element: {
|
|
261
|
+
type: element.type ?? "unknown",
|
|
262
|
+
label: element.label ?? element.name ?? "",
|
|
263
|
+
bounds: rect,
|
|
264
|
+
},
|
|
265
|
+
tappedAt: { x: centerX, y: centerY },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
error: err instanceof Error
|
|
272
|
+
? err.message
|
|
273
|
+
: "Unknown error during findAndTap",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Recursively search the WDA element tree for elements matching the query.
|
|
279
|
+
*/
|
|
280
|
+
searchElements(element, query) {
|
|
281
|
+
const results = [];
|
|
282
|
+
if (this.elementMatches(element, query)) {
|
|
283
|
+
results.push(element);
|
|
284
|
+
}
|
|
285
|
+
const children = element.children;
|
|
286
|
+
if (Array.isArray(children)) {
|
|
287
|
+
for (const child of children) {
|
|
288
|
+
results.push(...this.searchElements(child, query));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return results;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Check whether a single element matches the given query.
|
|
295
|
+
*/
|
|
296
|
+
elementMatches(element, query) {
|
|
297
|
+
if (query.text !== undefined) {
|
|
298
|
+
const searchText = query.text.toLowerCase();
|
|
299
|
+
const fields = [element.value, element.label, element.name]
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.map(v => String(v).toLowerCase());
|
|
302
|
+
if (!fields.some(f => f.includes(searchText))) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (query.accessibilityLabel !== undefined) {
|
|
307
|
+
const label = String(element.label ?? element.name ?? "");
|
|
308
|
+
if (!label
|
|
309
|
+
.toLowerCase()
|
|
310
|
+
.includes(query.accessibilityLabel.toLowerCase())) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (query.type !== undefined) {
|
|
315
|
+
const elemType = String(element.type ?? "");
|
|
316
|
+
if (!elemType.toLowerCase().includes(query.type.toLowerCase())) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// At least one query field must be specified
|
|
321
|
+
return (query.text !== undefined ||
|
|
322
|
+
query.accessibilityLabel !== undefined ||
|
|
323
|
+
query.type !== undefined);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Collect a summary of visible elements for debugging (when no match found).
|
|
327
|
+
*/
|
|
328
|
+
collectVisibleElements(element, limit) {
|
|
329
|
+
const results = [];
|
|
330
|
+
if (limit <= 0)
|
|
331
|
+
return results;
|
|
332
|
+
const label = String(element.label ?? element.name ?? "");
|
|
333
|
+
const text = String(element.value ?? "");
|
|
334
|
+
const type = String(element.type ?? "");
|
|
335
|
+
// Only include elements that have some identifying info
|
|
336
|
+
if (label || text) {
|
|
337
|
+
results.push({ type, label, text });
|
|
338
|
+
}
|
|
339
|
+
const children = element.children;
|
|
340
|
+
if (Array.isArray(children)) {
|
|
341
|
+
for (const child of children) {
|
|
342
|
+
const remaining = limit - results.length;
|
|
343
|
+
if (remaining <= 0)
|
|
344
|
+
break;
|
|
345
|
+
results.push(...this.collectVisibleElements(child, remaining));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return results;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Invalidate cached session and pixel ratio (e.g., after app restart).
|
|
352
|
+
*/
|
|
353
|
+
reset() {
|
|
354
|
+
this.sessionId = null;
|
|
355
|
+
this.pixelRatio = null;
|
|
356
|
+
}
|
|
357
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ceraph/react-native-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for React Native and Expo development workflow",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"react-native-mcp": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
23
|
+
"zod": "^3.25.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22",
|
|
27
|
+
"typescript": "^5.7",
|
|
28
|
+
"vitest": "^3"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run --passWithNoTests"
|
|
41
|
+
}
|
|
42
|
+
}
|