@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/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
+ }