@synnode/expo-metro-mcp 1.0.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 +93 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1049 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Sanders
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @synnode/expo-metro-mcp
|
|
2
|
+
|
|
3
|
+
MCP server that connects to a running Expo/Metro dev server and exposes its logs to Claude Code.
|
|
4
|
+
|
|
5
|
+
Uses the **Chrome DevTools Protocol (CDP)** inspector endpoint that Metro exposes — the same channel that React Native DevTools uses. Works with Expo SDK 50+ including the new architecture (Bridgeless/JSI).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd expo-metro-mcp
|
|
11
|
+
npm install && npm run build
|
|
12
|
+
|
|
13
|
+
# Register with Claude Code CLI
|
|
14
|
+
claude mcp add expo-metro node /absolute/path/to/expo-metro-mcp/dist/index.js
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Restart Claude Code after adding the server.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Expo / Metro dev server running (`npx expo start`)
|
|
22
|
+
- A device or emulator connected to Metro (the app must be running for logs to appear)
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Defaults — only override if needed
|
|
28
|
+
METRO_PORT=8081
|
|
29
|
+
METRO_HOST=localhost
|
|
30
|
+
LOG_BUFFER_SIZE=1000
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
If Metro runs on a different port:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
claude mcp add expo-metro --env METRO_PORT=8082 node /path/to/dist/index.js
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Available tools
|
|
40
|
+
|
|
41
|
+
| Tool | Description |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `get_logs` | Recent logs from the buffer. Optional: `lines`, `level` (`error`/`warn`/`info`/`log`/`debug`), `since` (e.g. `"30s"`, `"2m"`, unix timestamp) |
|
|
44
|
+
| `get_errors` | Errors with stack traces from the buffer. Optional: `lines` |
|
|
45
|
+
| `get_status` | Connection status, device name, and buffer statistics |
|
|
46
|
+
| `clear_logs` | Clear the log buffer |
|
|
47
|
+
| `watch_logs` | Poll for incoming logs for a time window. Optional: `duration` (e.g. `"10s"`, max `"30s"`), `level` |
|
|
48
|
+
| `connect` | Grab the CDP connection from Metro. Use after `disconnect` or when `get_status` shows disconnected |
|
|
49
|
+
| `disconnect` | Release the CDP connection so React Native DevTools can connect freely |
|
|
50
|
+
| `reload` | Reload the React Native app via Metro |
|
|
51
|
+
| `resolve_stack` | Resolve a stack trace against the Metro source map, showing original file/line instead of bundle offsets |
|
|
52
|
+
| `list_devices` | List active iOS simulators and Android emulators |
|
|
53
|
+
| `screenshot` | Take a screenshot of the active simulator/emulator. Returns the image directly. Optional: `platform`, `device_id` |
|
|
54
|
+
| `tap` | Tap at x,y coordinates on the active simulator/emulator. Optional: `platform`, `device_id` |
|
|
55
|
+
| `swipe` | Swipe from one coordinate to another. Optional: `duration_ms`, `platform`, `device_id` |
|
|
56
|
+
|
|
57
|
+
## Screenshot & UI automation
|
|
58
|
+
|
|
59
|
+
`screenshot`, `tap`, and `swipe` interact directly with your running simulator or emulator — no extra packages or paid plans needed.
|
|
60
|
+
|
|
61
|
+
**Requirements:**
|
|
62
|
+
- **iOS screenshots**: macOS with Xcode installed (`xcrun simctl` must be available)
|
|
63
|
+
- **iOS tap/swipe**: `idb` — Facebook's iOS Development Bridge
|
|
64
|
+
```bash
|
|
65
|
+
brew tap facebook/fb && brew install idb-companion
|
|
66
|
+
pip3 install fb-idb
|
|
67
|
+
```
|
|
68
|
+
- **Android**: `adb` in your PATH (part of Android SDK platform-tools) — tap, swipe and screenshot all work out of the box
|
|
69
|
+
|
|
70
|
+
**Notes:**
|
|
71
|
+
- If multiple devices are running, use `list_devices` to find the ID and pass it via `device_id`
|
|
72
|
+
- Coordinates are in points (iOS logical pixels) or pixels (Android)
|
|
73
|
+
- iOS screenshots work without idb — only tap/swipe require it
|
|
74
|
+
|
|
75
|
+
## Using alongside React Native DevTools
|
|
76
|
+
|
|
77
|
+
CDP only allows one client at a time. The MCP server does **not** auto-reconnect, so you can freely switch between it and DevTools:
|
|
78
|
+
|
|
79
|
+
1. Use `disconnect` to release the connection before opening DevTools
|
|
80
|
+
2. Open React Native DevTools as usual
|
|
81
|
+
3. When done, close DevTools and call `connect` to reattach the MCP server
|
|
82
|
+
|
|
83
|
+
`get_status` always shows whether the MCP is currently connected.
|
|
84
|
+
|
|
85
|
+
## How it works
|
|
86
|
+
|
|
87
|
+
Metro exposes a CDP WebSocket at `/inspector/debug`. On `connect`, the server calls `/json/list` to discover the active device target, then attaches via CDP and enables `Runtime.consoleAPICalled` events. Metro build errors (`build_failed`, `bundling_error`) are captured separately via the `/events` WebSocket, which reconnects automatically.
|
|
88
|
+
|
|
89
|
+
## Notes
|
|
90
|
+
|
|
91
|
+
- If Metro is not reachable on startup: the server starts normally, `get_status` returns `connected: false`. Call `connect` once your dev server is up.
|
|
92
|
+
- Memory is bounded by `LOG_BUFFER_SIZE` (circular buffer, oldest entries dropped first).
|
|
93
|
+
- The CDP connection may show an "unsupported debugging client" notice in Metro's terminal — this is harmless.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/metro-client.ts
|
|
8
|
+
import WebSocket from "ws";
|
|
9
|
+
import http from "http";
|
|
10
|
+
var METRO_PORT = parseInt(process.env.METRO_PORT ?? "8081", 10);
|
|
11
|
+
var METRO_HOST = process.env.METRO_HOST ?? "localhost";
|
|
12
|
+
var LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE ?? "1000", 10);
|
|
13
|
+
var MAX_BACKOFF_MS = 3e4;
|
|
14
|
+
var INITIAL_BACKOFF_MS = 1e3;
|
|
15
|
+
var CDP_TYPE_MAP = {
|
|
16
|
+
log: "log",
|
|
17
|
+
info: "info",
|
|
18
|
+
warning: "warn",
|
|
19
|
+
warn: "warn",
|
|
20
|
+
error: "error",
|
|
21
|
+
debug: "debug",
|
|
22
|
+
dir: "log",
|
|
23
|
+
dirxml: "log",
|
|
24
|
+
table: "log",
|
|
25
|
+
assert: "error"
|
|
26
|
+
};
|
|
27
|
+
function formatArgs(args) {
|
|
28
|
+
return args.map((a) => {
|
|
29
|
+
if (a.value !== void 0 && a.value !== null) return String(a.value);
|
|
30
|
+
if (a.description) return a.description;
|
|
31
|
+
return "";
|
|
32
|
+
}).filter(Boolean).join(" ");
|
|
33
|
+
}
|
|
34
|
+
async function fetchTargets(host, port) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const req = http.get(`http://${host}:${port}/json/list`, (res) => {
|
|
37
|
+
let body = "";
|
|
38
|
+
res.on("data", (chunk) => body += chunk);
|
|
39
|
+
res.on("end", () => {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(body);
|
|
42
|
+
if (Array.isArray(parsed)) {
|
|
43
|
+
resolve(parsed);
|
|
44
|
+
} else {
|
|
45
|
+
resolve([]);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
resolve([]);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
req.on("error", () => resolve([]));
|
|
53
|
+
req.setTimeout(2e3, () => {
|
|
54
|
+
req.destroy();
|
|
55
|
+
resolve([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
var MetroClient = class {
|
|
60
|
+
buffer = [];
|
|
61
|
+
cdpWs = null;
|
|
62
|
+
eventsWs = null;
|
|
63
|
+
_connected = false;
|
|
64
|
+
_currentTargetId = null;
|
|
65
|
+
_lastConnectedAt = null;
|
|
66
|
+
_totalReceived = 0;
|
|
67
|
+
_stopped = false;
|
|
68
|
+
_eventsBackoff = INITIAL_BACKOFF_MS;
|
|
69
|
+
_deviceTitle = null;
|
|
70
|
+
host = METRO_HOST;
|
|
71
|
+
port = METRO_PORT;
|
|
72
|
+
get connected() {
|
|
73
|
+
return this._connected;
|
|
74
|
+
}
|
|
75
|
+
get lastConnectedAt() {
|
|
76
|
+
return this._lastConnectedAt;
|
|
77
|
+
}
|
|
78
|
+
get totalReceived() {
|
|
79
|
+
return this._totalReceived;
|
|
80
|
+
}
|
|
81
|
+
get bufferedEntries() {
|
|
82
|
+
return this.buffer.length;
|
|
83
|
+
}
|
|
84
|
+
get deviceTitle() {
|
|
85
|
+
return this._deviceTitle;
|
|
86
|
+
}
|
|
87
|
+
start() {
|
|
88
|
+
this._stopped = false;
|
|
89
|
+
this.connectEvents();
|
|
90
|
+
}
|
|
91
|
+
stop() {
|
|
92
|
+
this._stopped = true;
|
|
93
|
+
if (this.cdpWs) {
|
|
94
|
+
this.cdpWs.terminate();
|
|
95
|
+
this.cdpWs = null;
|
|
96
|
+
}
|
|
97
|
+
if (this.eventsWs) {
|
|
98
|
+
this.eventsWs.terminate();
|
|
99
|
+
this.eventsWs = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
disconnect() {
|
|
103
|
+
if (this.cdpWs) {
|
|
104
|
+
this.cdpWs.terminate();
|
|
105
|
+
this.cdpWs = null;
|
|
106
|
+
}
|
|
107
|
+
this._connected = false;
|
|
108
|
+
this._currentTargetId = null;
|
|
109
|
+
this._deviceTitle = null;
|
|
110
|
+
}
|
|
111
|
+
getEntries(options = {}) {
|
|
112
|
+
let entries = this.buffer;
|
|
113
|
+
if (options.since !== void 0) {
|
|
114
|
+
entries = entries.filter((e) => e.timestamp >= options.since);
|
|
115
|
+
}
|
|
116
|
+
if (options.level) {
|
|
117
|
+
entries = entries.filter((e) => e.level === options.level);
|
|
118
|
+
}
|
|
119
|
+
const lines = options.lines ?? 50;
|
|
120
|
+
return entries.slice(-lines);
|
|
121
|
+
}
|
|
122
|
+
clearBuffer() {
|
|
123
|
+
const count = this.buffer.length;
|
|
124
|
+
this.buffer = [];
|
|
125
|
+
return count;
|
|
126
|
+
}
|
|
127
|
+
async grabConnection() {
|
|
128
|
+
await this.checkForNewTarget();
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
130
|
+
if (this._connected) {
|
|
131
|
+
return `Connected to ${this._deviceTitle ?? "device"}.`;
|
|
132
|
+
}
|
|
133
|
+
return "No device found. Is Metro running with a connected device?";
|
|
134
|
+
}
|
|
135
|
+
addEntry(entry) {
|
|
136
|
+
this._totalReceived++;
|
|
137
|
+
this.buffer.push(entry);
|
|
138
|
+
if (this.buffer.length > LOG_BUFFER_SIZE) {
|
|
139
|
+
this.buffer.shift();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async checkForNewTarget() {
|
|
143
|
+
const targets = await fetchTargets(this.host, this.port);
|
|
144
|
+
if (!targets.length) {
|
|
145
|
+
if (this._connected) {
|
|
146
|
+
this._connected = false;
|
|
147
|
+
this._currentTargetId = null;
|
|
148
|
+
this._deviceTitle = null;
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const target = targets[0];
|
|
153
|
+
if (target.id === this._currentTargetId && this.cdpWs?.readyState === WebSocket.OPEN) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.connectCdp(target);
|
|
157
|
+
}
|
|
158
|
+
connectCdp(target) {
|
|
159
|
+
if (this.cdpWs) {
|
|
160
|
+
this.cdpWs.terminate();
|
|
161
|
+
this.cdpWs = null;
|
|
162
|
+
}
|
|
163
|
+
this._currentTargetId = target.id;
|
|
164
|
+
this._deviceTitle = target.title ?? null;
|
|
165
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
166
|
+
this.cdpWs = ws;
|
|
167
|
+
ws.on("open", () => {
|
|
168
|
+
this._connected = true;
|
|
169
|
+
this._lastConnectedAt = /* @__PURE__ */ new Date();
|
|
170
|
+
ws.send(JSON.stringify({ id: 1, method: "Runtime.enable", params: {} }));
|
|
171
|
+
});
|
|
172
|
+
ws.on("message", (data) => {
|
|
173
|
+
let msg;
|
|
174
|
+
try {
|
|
175
|
+
msg = JSON.parse(data.toString());
|
|
176
|
+
} catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (msg.method === "Runtime.consoleAPICalled" && msg.params) {
|
|
180
|
+
this.handleConsoleEvent(msg.params);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
ws.on("close", () => {
|
|
184
|
+
if (this.cdpWs === ws) {
|
|
185
|
+
this._connected = false;
|
|
186
|
+
this.cdpWs = null;
|
|
187
|
+
this._currentTargetId = null;
|
|
188
|
+
this._deviceTitle = null;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
ws.on("error", () => {
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
handleConsoleEvent(params) {
|
|
195
|
+
const type = typeof params.type === "string" ? params.type : "log";
|
|
196
|
+
const level = CDP_TYPE_MAP[type] ?? "log";
|
|
197
|
+
const args = Array.isArray(params.args) ? params.args : [];
|
|
198
|
+
const message = formatArgs(args);
|
|
199
|
+
const ts = typeof params.timestamp === "number" ? Math.round(params.timestamp) : Date.now();
|
|
200
|
+
if (!message) return;
|
|
201
|
+
const stackTrace = params.stackTrace;
|
|
202
|
+
const rawFrames = stackTrace?.callFrames?.filter((f) => f.url && !f.url.startsWith("native")).map((f) => ({
|
|
203
|
+
functionName: f.functionName ?? "(anonymous)",
|
|
204
|
+
url: f.url,
|
|
205
|
+
line: f.lineNumber ?? 0,
|
|
206
|
+
col: f.columnNumber ?? 0
|
|
207
|
+
}));
|
|
208
|
+
const rawMessage = message.includes("http://") ? message : void 0;
|
|
209
|
+
this.addEntry({ timestamp: ts, level, message, rawMessage, rawFrames: rawFrames?.length ? rawFrames : void 0 });
|
|
210
|
+
}
|
|
211
|
+
// Also listen to /events for Metro build errors (build_failed, bundling_error)
|
|
212
|
+
connectEvents() {
|
|
213
|
+
if (this._stopped) return;
|
|
214
|
+
const url = `ws://${this.host}:${this.port}/events`;
|
|
215
|
+
let ws;
|
|
216
|
+
try {
|
|
217
|
+
ws = new WebSocket(url);
|
|
218
|
+
} catch {
|
|
219
|
+
this.scheduleEventsReconnect();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.eventsWs = ws;
|
|
223
|
+
ws.on("open", () => {
|
|
224
|
+
this._eventsBackoff = INITIAL_BACKOFF_MS;
|
|
225
|
+
});
|
|
226
|
+
ws.on("message", (data) => {
|
|
227
|
+
let event = null;
|
|
228
|
+
try {
|
|
229
|
+
event = JSON.parse(data.toString());
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (event.type === "build_failed" || event.type === "bundling_error") {
|
|
234
|
+
this.addEntry({
|
|
235
|
+
timestamp: Date.now(),
|
|
236
|
+
level: "error",
|
|
237
|
+
message: event.message ?? event.type
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
ws.on("close", () => {
|
|
242
|
+
if (this.eventsWs === ws) {
|
|
243
|
+
this.eventsWs = null;
|
|
244
|
+
this.scheduleEventsReconnect();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
ws.on("error", () => {
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
scheduleEventsReconnect() {
|
|
251
|
+
if (this._stopped) return;
|
|
252
|
+
setTimeout(() => {
|
|
253
|
+
this._eventsBackoff = Math.min(this._eventsBackoff * 2, MAX_BACKOFF_MS);
|
|
254
|
+
this.connectEvents();
|
|
255
|
+
}, this._eventsBackoff);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
var metroClient = new MetroClient();
|
|
259
|
+
|
|
260
|
+
// src/tools/get-logs.ts
|
|
261
|
+
import { z } from "zod";
|
|
262
|
+
|
|
263
|
+
// src/tools/format.ts
|
|
264
|
+
var BUNDLE_URL_RE = /\(https?:\/\/[^)]+\.bundle[^)]*:(\d+:\d+)\)/g;
|
|
265
|
+
function cleanMessage(message) {
|
|
266
|
+
return message.replace(BUNDLE_URL_RE, "(:$1)");
|
|
267
|
+
}
|
|
268
|
+
function formatTime(ts) {
|
|
269
|
+
const d = new Date(ts);
|
|
270
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
271
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
272
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
273
|
+
return `${hh}:${mm}:${ss}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/tools/get-logs.ts
|
|
277
|
+
var GetLogsSchema = z.object({
|
|
278
|
+
lines: z.coerce.number().int().min(1).max(500).optional().default(50),
|
|
279
|
+
level: z.enum(["error", "warn", "info", "log", "debug"]).optional(),
|
|
280
|
+
since: z.string().optional()
|
|
281
|
+
});
|
|
282
|
+
function parseSince(since) {
|
|
283
|
+
const asNum = Number(since);
|
|
284
|
+
if (!isNaN(asNum) && asNum > 1e9) {
|
|
285
|
+
return asNum < 1e12 ? asNum * 1e3 : asNum;
|
|
286
|
+
}
|
|
287
|
+
const match = since.match(/^(\d+(?:\.\d+)?)(s|m|h)$/);
|
|
288
|
+
if (match) {
|
|
289
|
+
const value = parseFloat(match[1]);
|
|
290
|
+
const unit = match[2];
|
|
291
|
+
const multipliers = { s: 1e3, m: 6e4, h: 36e5 };
|
|
292
|
+
return Date.now() - value * multipliers[unit];
|
|
293
|
+
}
|
|
294
|
+
return asNum;
|
|
295
|
+
}
|
|
296
|
+
function getLogs(params) {
|
|
297
|
+
const since = params.since ? parseSince(params.since) : void 0;
|
|
298
|
+
const entries = metroClient.getEntries({
|
|
299
|
+
lines: params.lines,
|
|
300
|
+
level: params.level,
|
|
301
|
+
since
|
|
302
|
+
});
|
|
303
|
+
if (entries.length === 0) {
|
|
304
|
+
return "No log entries found.";
|
|
305
|
+
}
|
|
306
|
+
return entries.map((e) => `[${formatTime(e.timestamp)}] [${e.level.toUpperCase()}] ${cleanMessage(e.message)}`).join("\n");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/tools/get-errors.ts
|
|
310
|
+
import { z as z2 } from "zod";
|
|
311
|
+
var GetErrorsSchema = z2.object({
|
|
312
|
+
lines: z2.coerce.number().int().min(1).max(200).optional().default(20)
|
|
313
|
+
});
|
|
314
|
+
function getErrors(params) {
|
|
315
|
+
const entries = metroClient.getEntries({ level: "error", lines: params.lines });
|
|
316
|
+
if (entries.length === 0) {
|
|
317
|
+
return "No errors in buffer.";
|
|
318
|
+
}
|
|
319
|
+
return entries.map((e) => `[${formatTime(e.timestamp)}] [ERROR]
|
|
320
|
+
${cleanMessage(e.message)}`).join("\n\n---\n\n");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/tools/get-status.ts
|
|
324
|
+
function getStatus() {
|
|
325
|
+
const status = {
|
|
326
|
+
connected: metroClient.connected,
|
|
327
|
+
host: metroClient.host,
|
|
328
|
+
port: metroClient.port,
|
|
329
|
+
device: metroClient.deviceTitle,
|
|
330
|
+
buffered_entries: metroClient.bufferedEntries,
|
|
331
|
+
last_connected_at: metroClient.lastConnectedAt?.toISOString() ?? null,
|
|
332
|
+
total_received: metroClient.totalReceived,
|
|
333
|
+
expo_sdk_version: null
|
|
334
|
+
};
|
|
335
|
+
return JSON.stringify(status, null, 2);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/tools/clear-logs.ts
|
|
339
|
+
function clearLogs() {
|
|
340
|
+
const count = metroClient.clearBuffer();
|
|
341
|
+
return `Cleared ${count} log ${count === 1 ? "entry" : "entries"} from the buffer.`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/tools/watch-logs.ts
|
|
345
|
+
import { z as z3 } from "zod";
|
|
346
|
+
var WatchLogsSchema = z3.object({
|
|
347
|
+
duration: z3.string().optional().default("10s"),
|
|
348
|
+
level: z3.enum(["error", "warn", "info", "log", "debug"]).optional()
|
|
349
|
+
});
|
|
350
|
+
function parseDurationMs(duration) {
|
|
351
|
+
const match = duration.match(/^(\d+(?:\.\d+)?)(s|m)$/);
|
|
352
|
+
if (!match) return 1e4;
|
|
353
|
+
const value = parseFloat(match[1]);
|
|
354
|
+
const unit = match[2];
|
|
355
|
+
const ms = unit === "m" ? value * 6e4 : value * 1e3;
|
|
356
|
+
return Math.min(ms, 3e4);
|
|
357
|
+
}
|
|
358
|
+
var POLL_INTERVAL_MS = 500;
|
|
359
|
+
async function watchLogs(params) {
|
|
360
|
+
if (!metroClient.connected) {
|
|
361
|
+
return "Metro is not connected. Start Expo dev server and try again.";
|
|
362
|
+
}
|
|
363
|
+
const durationMs = parseDurationMs(params.duration);
|
|
364
|
+
const startBufferSize = metroClient.bufferedEntries;
|
|
365
|
+
const startTime = Date.now();
|
|
366
|
+
const levelFilter = params.level;
|
|
367
|
+
await new Promise((resolve) => {
|
|
368
|
+
const interval = setInterval(() => {
|
|
369
|
+
if (Date.now() - startTime >= durationMs) {
|
|
370
|
+
clearInterval(interval);
|
|
371
|
+
resolve();
|
|
372
|
+
}
|
|
373
|
+
}, POLL_INTERVAL_MS);
|
|
374
|
+
});
|
|
375
|
+
const all = metroClient.getEntries({ lines: metroClient.bufferedEntries });
|
|
376
|
+
const newEntries = all.slice(startBufferSize);
|
|
377
|
+
const filtered = levelFilter ? newEntries.filter((e) => e.level === levelFilter) : newEntries;
|
|
378
|
+
if (filtered.length === 0) {
|
|
379
|
+
return `No logs received during ${params.duration} window.`;
|
|
380
|
+
}
|
|
381
|
+
return filtered.map((e) => `[${formatTime(e.timestamp)}] [${e.level.toUpperCase()}] ${cleanMessage(e.message)}`).join("\n");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/tools/reload.ts
|
|
385
|
+
import http2 from "http";
|
|
386
|
+
var METRO_PORT2 = parseInt(process.env.METRO_PORT ?? "8081", 10);
|
|
387
|
+
var METRO_HOST2 = process.env.METRO_HOST ?? "localhost";
|
|
388
|
+
async function reload() {
|
|
389
|
+
return new Promise((resolve) => {
|
|
390
|
+
const req = http2.request(
|
|
391
|
+
{ hostname: METRO_HOST2, port: METRO_PORT2, path: "/reload", method: "POST" },
|
|
392
|
+
(res) => {
|
|
393
|
+
res.resume();
|
|
394
|
+
if (res.statusCode === 200) {
|
|
395
|
+
resolve("App reloaded.");
|
|
396
|
+
} else {
|
|
397
|
+
resolve(`Reload failed: HTTP ${res.statusCode}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
);
|
|
401
|
+
req.on("error", (e) => resolve(`Reload failed: ${e.message}`));
|
|
402
|
+
req.setTimeout(3e3, () => {
|
|
403
|
+
req.destroy();
|
|
404
|
+
resolve("Reload failed: timeout");
|
|
405
|
+
});
|
|
406
|
+
req.end();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/tools/resolve-stack.ts
|
|
411
|
+
import { z as z4 } from "zod";
|
|
412
|
+
import http3 from "http";
|
|
413
|
+
import { SourceMapConsumer } from "source-map";
|
|
414
|
+
var ResolveStackSchema = z4.object({
|
|
415
|
+
message: z4.string().optional()
|
|
416
|
+
});
|
|
417
|
+
var cache = /* @__PURE__ */ new Map();
|
|
418
|
+
var CACHE_TTL_MS = 6e4;
|
|
419
|
+
function toLocalhostUrl(url, host, port) {
|
|
420
|
+
return url.replace(/^https?:\/\/[^/]+/, `http://${host}:${port}`);
|
|
421
|
+
}
|
|
422
|
+
function toSourceMapUrl(bundleUrl) {
|
|
423
|
+
const paramsMatch = bundleUrl.match(/(?:\/\/&|\?)(.+)$/);
|
|
424
|
+
const params = paramsMatch ? paramsMatch[1] : "dev=true&minify=false";
|
|
425
|
+
const mapBase = bundleUrl.replace(/(\/[^/?]+)\.bundle.*$/, "$1.map");
|
|
426
|
+
return `${mapBase}?${params}`;
|
|
427
|
+
}
|
|
428
|
+
async function fetchSourceMap(mapUrl) {
|
|
429
|
+
const cached = cache.get(mapUrl);
|
|
430
|
+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
431
|
+
return cached.consumer;
|
|
432
|
+
}
|
|
433
|
+
return new Promise((resolve) => {
|
|
434
|
+
const url = new URL(mapUrl);
|
|
435
|
+
const req = http3.get({ hostname: url.hostname, port: Number(url.port) || 8081, path: url.pathname + url.search }, (res) => {
|
|
436
|
+
const chunks = [];
|
|
437
|
+
res.on("data", (c) => chunks.push(c));
|
|
438
|
+
res.on("end", async () => {
|
|
439
|
+
if (res.statusCode !== 200) {
|
|
440
|
+
resolve(null);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const raw = JSON.parse(Buffer.concat(chunks).toString());
|
|
445
|
+
const consumer = await SourceMapConsumer.with(raw, null, (c) => c);
|
|
446
|
+
cache.set(mapUrl, { consumer, fetchedAt: Date.now() });
|
|
447
|
+
resolve(consumer);
|
|
448
|
+
} catch {
|
|
449
|
+
resolve(null);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
req.on("error", () => resolve(null));
|
|
454
|
+
req.setTimeout(1e4, () => {
|
|
455
|
+
req.destroy();
|
|
456
|
+
resolve(null);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
function invalidateSourceMapCache() {
|
|
461
|
+
cache.forEach((v) => v.consumer.destroy());
|
|
462
|
+
cache.clear();
|
|
463
|
+
}
|
|
464
|
+
function parseMessageFrames(rawMessage) {
|
|
465
|
+
const re = /at\s+([\w$.<>[\] ]+?)\s+\((https?:\/\/[^)]+\.bundle[^)]*):(\d+):(\d+)\)/g;
|
|
466
|
+
const frames = [];
|
|
467
|
+
let m;
|
|
468
|
+
while ((m = re.exec(rawMessage)) !== null) {
|
|
469
|
+
frames.push({
|
|
470
|
+
functionName: m[1].trim(),
|
|
471
|
+
url: m[2],
|
|
472
|
+
line: parseInt(m[3]) - 1,
|
|
473
|
+
// convert to 0-indexed
|
|
474
|
+
col: parseInt(m[4])
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return frames;
|
|
478
|
+
}
|
|
479
|
+
async function resolveFrames(frames, fallbackMapUrl) {
|
|
480
|
+
const lines = [];
|
|
481
|
+
const byMap = /* @__PURE__ */ new Map();
|
|
482
|
+
for (const frame of frames) {
|
|
483
|
+
const local = toLocalhostUrl(frame.url, metroClient.host, metroClient.port);
|
|
484
|
+
const mapUrl = toSourceMapUrl(local);
|
|
485
|
+
if (!byMap.has(mapUrl)) byMap.set(mapUrl, []);
|
|
486
|
+
byMap.get(mapUrl).push(frame);
|
|
487
|
+
}
|
|
488
|
+
const consumers = /* @__PURE__ */ new Map();
|
|
489
|
+
await Promise.all([...byMap.keys()].map(async (mapUrl) => {
|
|
490
|
+
consumers.set(mapUrl, await fetchSourceMap(mapUrl));
|
|
491
|
+
}));
|
|
492
|
+
for (const frame of frames) {
|
|
493
|
+
const local = toLocalhostUrl(frame.url, metroClient.host, metroClient.port);
|
|
494
|
+
const mapUrl = toSourceMapUrl(local);
|
|
495
|
+
const consumer = consumers.get(mapUrl) ?? null;
|
|
496
|
+
if (consumer) {
|
|
497
|
+
const pos = consumer.originalPositionFor({ line: frame.line + 1, column: frame.col });
|
|
498
|
+
if (pos.source) {
|
|
499
|
+
const src = pos.source.replace(/^.*\/\/\//, "").replace(/\?.*$/, "");
|
|
500
|
+
lines.push(` at ${frame.functionName} (${src}:${pos.line}:${pos.column})`);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
lines.push(` at ${frame.functionName} (:${frame.line + 1}:${frame.col})`);
|
|
505
|
+
}
|
|
506
|
+
return lines;
|
|
507
|
+
}
|
|
508
|
+
async function resolveStack(params) {
|
|
509
|
+
const errors = metroClient.getEntries({ level: "error", lines: 50 });
|
|
510
|
+
const entry = params.message ? [...errors].reverse().find((e) => e.message.includes(params.message)) : errors.at(-1);
|
|
511
|
+
if (!entry) return "No error entries in buffer.";
|
|
512
|
+
const errorTitle = `[ERROR] ${entry.message.split("\n")[0]}`;
|
|
513
|
+
if (!entry.rawMessage) {
|
|
514
|
+
return `${errorTitle}
|
|
515
|
+
|
|
516
|
+
No stack frames available.`;
|
|
517
|
+
}
|
|
518
|
+
const allFrames = parseMessageFrames(entry.rawMessage);
|
|
519
|
+
if (!allFrames.length) return `${errorTitle}
|
|
520
|
+
|
|
521
|
+
No stack frames available.`;
|
|
522
|
+
const resolvedLines = await resolveFrames(allFrames);
|
|
523
|
+
const userLines = resolvedLines.filter(
|
|
524
|
+
(line) => !line.includes("node_modules") && !line.includes("(:")
|
|
525
|
+
// strip unresolved bundle offsets
|
|
526
|
+
);
|
|
527
|
+
const output = [errorTitle, ""];
|
|
528
|
+
if (userLines.length) {
|
|
529
|
+
output.push(...userLines);
|
|
530
|
+
} else {
|
|
531
|
+
output.push(...resolvedLines);
|
|
532
|
+
}
|
|
533
|
+
return output.join("\n");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// src/tools/screenshot.ts
|
|
537
|
+
import { z as z5 } from "zod";
|
|
538
|
+
import { execSync as execSync2 } from "child_process";
|
|
539
|
+
import * as fs from "fs";
|
|
540
|
+
import * as os from "os";
|
|
541
|
+
import * as path from "path";
|
|
542
|
+
|
|
543
|
+
// src/tools/devices.ts
|
|
544
|
+
import { execSync } from "child_process";
|
|
545
|
+
function runCommand(cmd) {
|
|
546
|
+
try {
|
|
547
|
+
return execSync(cmd, { timeout: 5e3, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
548
|
+
} catch {
|
|
549
|
+
return "";
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function isCommandAvailable(cmd) {
|
|
553
|
+
try {
|
|
554
|
+
execSync(`which ${cmd}`, { timeout: 2e3, stdio: "ignore" });
|
|
555
|
+
return true;
|
|
556
|
+
} catch {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function listIOSDevices() {
|
|
561
|
+
if (!isCommandAvailable("xcrun")) return [];
|
|
562
|
+
const output = runCommand("xcrun simctl list devices booted --json");
|
|
563
|
+
if (!output) return [];
|
|
564
|
+
try {
|
|
565
|
+
const json = JSON.parse(output);
|
|
566
|
+
const devices = [];
|
|
567
|
+
for (const [, sims] of Object.entries(json.devices)) {
|
|
568
|
+
for (const sim of sims) {
|
|
569
|
+
if (sim.state === "Booted") {
|
|
570
|
+
devices.push({ id: sim.udid, name: sim.name, platform: "ios" });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return devices;
|
|
575
|
+
} catch {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function listAndroidDevices() {
|
|
580
|
+
if (!isCommandAvailable("adb")) return [];
|
|
581
|
+
const output = runCommand("adb devices -l");
|
|
582
|
+
if (!output) return [];
|
|
583
|
+
const devices = [];
|
|
584
|
+
const lines = output.split("\n").slice(1);
|
|
585
|
+
for (const line of lines) {
|
|
586
|
+
const parts = line.trim().split(/\s+/);
|
|
587
|
+
if (parts.length < 2 || parts[1] !== "device") continue;
|
|
588
|
+
const id = parts[0];
|
|
589
|
+
const modelMatch = line.match(/model:(\S+)/);
|
|
590
|
+
const name = modelMatch ? modelMatch[1].replace(/_/g, " ") : id;
|
|
591
|
+
devices.push({ id, name, platform: "android" });
|
|
592
|
+
}
|
|
593
|
+
return devices;
|
|
594
|
+
}
|
|
595
|
+
function listAllDevices() {
|
|
596
|
+
return [...listIOSDevices(), ...listAndroidDevices()];
|
|
597
|
+
}
|
|
598
|
+
function pickDevice(platform, deviceId) {
|
|
599
|
+
const all = listAllDevices();
|
|
600
|
+
if (!all.length) return null;
|
|
601
|
+
if (deviceId) {
|
|
602
|
+
return all.find((d) => d.id === deviceId || d.name.toLowerCase().includes(deviceId.toLowerCase())) ?? null;
|
|
603
|
+
}
|
|
604
|
+
if (platform === "ios") {
|
|
605
|
+
return all.find((d) => d.platform === "ios") ?? null;
|
|
606
|
+
}
|
|
607
|
+
if (platform === "android") {
|
|
608
|
+
return all.find((d) => d.platform === "android") ?? null;
|
|
609
|
+
}
|
|
610
|
+
return all.find((d) => d.platform === "ios") ?? all[0];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/tools/screenshot.ts
|
|
614
|
+
var ScreenshotSchema = z5.object({
|
|
615
|
+
device_id: z5.string().optional(),
|
|
616
|
+
platform: z5.enum(["ios", "android"]).optional()
|
|
617
|
+
});
|
|
618
|
+
function getIosScaleFactor(imagePath) {
|
|
619
|
+
try {
|
|
620
|
+
const output = execSync2(`sips -g pixelWidth -g pixelHeight "${imagePath}"`, {
|
|
621
|
+
timeout: 5e3,
|
|
622
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
623
|
+
}).toString();
|
|
624
|
+
const widthMatch = output.match(/pixelWidth:\s*(\d+)/);
|
|
625
|
+
const heightMatch = output.match(/pixelHeight:\s*(\d+)/);
|
|
626
|
+
if (!widthMatch || !heightMatch) return 1;
|
|
627
|
+
const pixelWidth = parseInt(widthMatch[1]);
|
|
628
|
+
const pixelHeight = parseInt(heightMatch[1]);
|
|
629
|
+
const longSide = Math.max(pixelWidth, pixelHeight);
|
|
630
|
+
if (longSide >= 2500 && pixelWidth % 3 === 0) return 3;
|
|
631
|
+
if (longSide >= 2500) return 3;
|
|
632
|
+
if (longSide >= 1334) return 2;
|
|
633
|
+
return 1;
|
|
634
|
+
} catch {
|
|
635
|
+
return 1;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function captureIOS(deviceId, outputPath) {
|
|
639
|
+
execSync2(`xcrun simctl io "${deviceId}" screenshot "${outputPath}"`, {
|
|
640
|
+
timeout: 1e4,
|
|
641
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
642
|
+
});
|
|
643
|
+
const scale = getIosScaleFactor(outputPath);
|
|
644
|
+
if (scale > 1) {
|
|
645
|
+
try {
|
|
646
|
+
const sipsOut = execSync2(`sips -g pixelWidth -g pixelHeight "${outputPath}"`, {
|
|
647
|
+
timeout: 5e3,
|
|
648
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
649
|
+
}).toString();
|
|
650
|
+
const wMatch = sipsOut.match(/pixelWidth:\s*(\d+)/);
|
|
651
|
+
const hMatch = sipsOut.match(/pixelHeight:\s*(\d+)/);
|
|
652
|
+
if (wMatch && hMatch) {
|
|
653
|
+
const logicalWidth = Math.round(parseInt(wMatch[1]) / scale);
|
|
654
|
+
const logicalHeight = Math.round(parseInt(hMatch[1]) / scale);
|
|
655
|
+
const resizedPath = outputPath.replace(".png", "-points.png");
|
|
656
|
+
execSync2(`sips -z ${logicalHeight} ${logicalWidth} "${outputPath}" --out "${resizedPath}"`, {
|
|
657
|
+
timeout: 1e4,
|
|
658
|
+
stdio: "ignore"
|
|
659
|
+
});
|
|
660
|
+
fs.renameSync(resizedPath, outputPath);
|
|
661
|
+
}
|
|
662
|
+
} catch {
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function captureAndroid(deviceId, outputPath) {
|
|
667
|
+
const tmpDevice = `/sdcard/mcp_screenshot_${Date.now()}.png`;
|
|
668
|
+
execSync2(`adb -s "${deviceId}" shell screencap -p "${tmpDevice}"`, {
|
|
669
|
+
timeout: 1e4,
|
|
670
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
671
|
+
});
|
|
672
|
+
execSync2(`adb -s "${deviceId}" pull "${tmpDevice}" "${outputPath}"`, {
|
|
673
|
+
timeout: 1e4,
|
|
674
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
675
|
+
});
|
|
676
|
+
execSync2(`adb -s "${deviceId}" shell rm "${tmpDevice}"`, {
|
|
677
|
+
timeout: 5e3,
|
|
678
|
+
stdio: "ignore"
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
function screenshot(params) {
|
|
682
|
+
const devices = listAllDevices();
|
|
683
|
+
if (!devices.length) {
|
|
684
|
+
return {
|
|
685
|
+
type: "text",
|
|
686
|
+
text: "No active simulators or emulators found. Start a simulator (iOS) or emulator (Android) first."
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
const device = pickDevice(params.platform, params.device_id);
|
|
690
|
+
if (!device) {
|
|
691
|
+
return {
|
|
692
|
+
type: "text",
|
|
693
|
+
text: `No matching device found. Available: ${devices.map((d) => `${d.name} (${d.platform})`).join(", ")}`
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const outputPath = path.join(os.tmpdir(), `expo-mcp-screenshot-${Date.now()}.png`);
|
|
697
|
+
try {
|
|
698
|
+
if (device.platform === "ios") {
|
|
699
|
+
captureIOS(device.id, outputPath);
|
|
700
|
+
} else {
|
|
701
|
+
captureAndroid(device.id, outputPath);
|
|
702
|
+
}
|
|
703
|
+
const imageData = fs.readFileSync(outputPath).toString("base64");
|
|
704
|
+
fs.unlinkSync(outputPath);
|
|
705
|
+
return {
|
|
706
|
+
type: "image",
|
|
707
|
+
data: imageData,
|
|
708
|
+
mimeType: "image/png"
|
|
709
|
+
};
|
|
710
|
+
} catch (err) {
|
|
711
|
+
try {
|
|
712
|
+
fs.unlinkSync(outputPath);
|
|
713
|
+
} catch {
|
|
714
|
+
}
|
|
715
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
716
|
+
return { type: "text", text: `Screenshot failed: ${msg}` };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/tools/tap.ts
|
|
721
|
+
import { z as z6 } from "zod";
|
|
722
|
+
import { execSync as execSync3 } from "child_process";
|
|
723
|
+
|
|
724
|
+
// src/tools/idb-companion.ts
|
|
725
|
+
import { spawn } from "child_process";
|
|
726
|
+
var companionProcess = null;
|
|
727
|
+
var companionUdid = null;
|
|
728
|
+
function ensureIdbCompanion(udid) {
|
|
729
|
+
if (companionProcess && !companionProcess.killed && companionUdid === udid) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (companionProcess && !companionProcess.killed) {
|
|
733
|
+
companionProcess.kill();
|
|
734
|
+
}
|
|
735
|
+
companionProcess = spawn("idb_companion", ["--udid", udid], {
|
|
736
|
+
detached: false,
|
|
737
|
+
stdio: "ignore"
|
|
738
|
+
});
|
|
739
|
+
companionUdid = udid;
|
|
740
|
+
companionProcess.on("exit", () => {
|
|
741
|
+
companionProcess = null;
|
|
742
|
+
companionUdid = null;
|
|
743
|
+
});
|
|
744
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 800);
|
|
745
|
+
}
|
|
746
|
+
process.on("exit", () => companionProcess?.kill());
|
|
747
|
+
process.on("SIGTERM", () => {
|
|
748
|
+
companionProcess?.kill();
|
|
749
|
+
process.exit(0);
|
|
750
|
+
});
|
|
751
|
+
process.on("SIGINT", () => {
|
|
752
|
+
companionProcess?.kill();
|
|
753
|
+
process.exit(0);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// src/tools/tap.ts
|
|
757
|
+
var TapSchema = z6.object({
|
|
758
|
+
x: z6.number().int().describe("X coordinate in points/pixels"),
|
|
759
|
+
y: z6.number().int().describe("Y coordinate in points/pixels"),
|
|
760
|
+
device_id: z6.string().optional(),
|
|
761
|
+
platform: z6.enum(["ios", "android"]).optional()
|
|
762
|
+
});
|
|
763
|
+
var SwipeSchema = z6.object({
|
|
764
|
+
x1: z6.number().int(),
|
|
765
|
+
y1: z6.number().int(),
|
|
766
|
+
x2: z6.number().int(),
|
|
767
|
+
y2: z6.number().int(),
|
|
768
|
+
duration_ms: z6.number().int().min(50).max(5e3).optional().default(300),
|
|
769
|
+
device_id: z6.string().optional(),
|
|
770
|
+
platform: z6.enum(["ios", "android"]).optional()
|
|
771
|
+
});
|
|
772
|
+
function isIdbAvailable() {
|
|
773
|
+
try {
|
|
774
|
+
execSync3("which idb", { timeout: 2e3, stdio: "ignore" });
|
|
775
|
+
return true;
|
|
776
|
+
} catch {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
function tapIOS(deviceId, x, y) {
|
|
781
|
+
if (isIdbAvailable()) {
|
|
782
|
+
ensureIdbCompanion(deviceId);
|
|
783
|
+
execSync3(`idb ui tap ${x} ${y} --udid "${deviceId}"`, {
|
|
784
|
+
timeout: 5e3,
|
|
785
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
786
|
+
});
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const touchJson = JSON.stringify({ touches: [{ x, y, action: "began" }] });
|
|
790
|
+
try {
|
|
791
|
+
execSync3(`echo '${touchJson}' | xcrun simctl io "${deviceId}" sendtouchJSON -`, {
|
|
792
|
+
timeout: 5e3,
|
|
793
|
+
stdio: ["pipe", "ignore", "pipe"]
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
} catch {
|
|
797
|
+
}
|
|
798
|
+
throw new Error(
|
|
799
|
+
`iOS tap requires idb (Facebook iOS Development Bridge).
|
|
800
|
+
Install via: brew install idb-companion && pip3 install fb-idb
|
|
801
|
+
Or: brew install facebook/fb/idb-companion`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
function tapAndroid(deviceId, x, y) {
|
|
805
|
+
execSync3(`adb -s "${deviceId}" shell input tap ${x} ${y}`, {
|
|
806
|
+
timeout: 5e3,
|
|
807
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
function swipeIOS(deviceId, x1, y1, x2, y2, durationMs) {
|
|
811
|
+
if (isIdbAvailable()) {
|
|
812
|
+
ensureIdbCompanion(deviceId);
|
|
813
|
+
const durationSec = (durationMs / 1e3).toFixed(2);
|
|
814
|
+
execSync3(`idb ui swipe ${x1} ${y1} ${x2} ${y2} ${durationSec} --udid "${deviceId}"`, {
|
|
815
|
+
timeout: durationMs + 5e3,
|
|
816
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
throw new Error(
|
|
821
|
+
`iOS swipe requires idb (Facebook iOS Development Bridge).
|
|
822
|
+
Install via: brew install idb-companion && pip3 install fb-idb`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
function swipeAndroid(deviceId, x1, y1, x2, y2, durationMs) {
|
|
826
|
+
execSync3(`adb -s "${deviceId}" shell input swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`, {
|
|
827
|
+
timeout: 1e4,
|
|
828
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
function tap(params) {
|
|
832
|
+
const devices = listAllDevices();
|
|
833
|
+
if (!devices.length) {
|
|
834
|
+
return "No active simulators or emulators found.";
|
|
835
|
+
}
|
|
836
|
+
const device = pickDevice(params.platform, params.device_id);
|
|
837
|
+
if (!device) {
|
|
838
|
+
return `No matching device found. Available: ${devices.map((d) => `${d.name} (${d.platform})`).join(", ")}`;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
if (device.platform === "ios") {
|
|
842
|
+
tapIOS(device.id, params.x, params.y);
|
|
843
|
+
} else {
|
|
844
|
+
tapAndroid(device.id, params.x, params.y);
|
|
845
|
+
}
|
|
846
|
+
return `Tapped (${params.x}, ${params.y}) on ${device.name} [${device.platform}].`;
|
|
847
|
+
} catch (err) {
|
|
848
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
849
|
+
return `Tap failed: ${msg}`;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function swipe(params) {
|
|
853
|
+
const devices = listAllDevices();
|
|
854
|
+
if (!devices.length) {
|
|
855
|
+
return "No active simulators or emulators found.";
|
|
856
|
+
}
|
|
857
|
+
const device = pickDevice(params.platform, params.device_id);
|
|
858
|
+
if (!device) {
|
|
859
|
+
return `No matching device found. Available: ${devices.map((d) => `${d.name} (${d.platform})`).join(", ")}`;
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
if (device.platform === "ios") {
|
|
863
|
+
swipeIOS(device.id, params.x1, params.y1, params.x2, params.y2, params.duration_ms);
|
|
864
|
+
return `Swiped from (${params.x1}, ${params.y1}) to (${params.x2}, ${params.y2}) on ${device.name} [ios]. Note: iOS swipe simulation is limited \u2014 use Android for reliable swipe gestures.`;
|
|
865
|
+
} else {
|
|
866
|
+
swipeAndroid(device.id, params.x1, params.y1, params.x2, params.y2, params.duration_ms);
|
|
867
|
+
return `Swiped from (${params.x1}, ${params.y1}) to (${params.x2}, ${params.y2}) on ${device.name} [android] over ${params.duration_ms}ms.`;
|
|
868
|
+
}
|
|
869
|
+
} catch (err) {
|
|
870
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
871
|
+
return `Swipe failed: ${msg}`;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/tools/list-devices.ts
|
|
876
|
+
function listDevices() {
|
|
877
|
+
const devices = listAllDevices();
|
|
878
|
+
if (!devices.length) {
|
|
879
|
+
return "No active simulators or emulators found.\n\n- iOS: start a simulator via Xcode or `xcrun simctl boot <device>`\n- Android: start an emulator via Android Studio or `emulator -avd <name>`";
|
|
880
|
+
}
|
|
881
|
+
const lines = devices.map((d) => `- ${d.name} [${d.platform}] id: ${d.id}`);
|
|
882
|
+
return `Active devices (${devices.length}):
|
|
883
|
+
${lines.join("\n")}`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/index.ts
|
|
887
|
+
var server = new McpServer({
|
|
888
|
+
name: "expo-metro-mcp",
|
|
889
|
+
version: "0.1.0"
|
|
890
|
+
});
|
|
891
|
+
server.registerTool(
|
|
892
|
+
"get_logs",
|
|
893
|
+
{
|
|
894
|
+
description: "Fetch recent logs from the Metro dev server buffer. Supports filtering by level and time.",
|
|
895
|
+
inputSchema: GetLogsSchema.shape
|
|
896
|
+
},
|
|
897
|
+
async (params) => {
|
|
898
|
+
const result = getLogs(params);
|
|
899
|
+
return { content: [{ type: "text", text: result }] };
|
|
900
|
+
}
|
|
901
|
+
);
|
|
902
|
+
server.registerTool(
|
|
903
|
+
"get_errors",
|
|
904
|
+
{
|
|
905
|
+
description: "Fetch recent errors from the Metro dev server buffer, with stack traces.",
|
|
906
|
+
inputSchema: GetErrorsSchema.shape
|
|
907
|
+
},
|
|
908
|
+
async (params) => {
|
|
909
|
+
const result = getErrors(params);
|
|
910
|
+
return { content: [{ type: "text", text: result }] };
|
|
911
|
+
}
|
|
912
|
+
);
|
|
913
|
+
server.registerTool(
|
|
914
|
+
"get_status",
|
|
915
|
+
{
|
|
916
|
+
description: "Check the connection status of the Metro dev server and buffer statistics."
|
|
917
|
+
},
|
|
918
|
+
async () => {
|
|
919
|
+
const result = getStatus();
|
|
920
|
+
return { content: [{ type: "text", text: result }] };
|
|
921
|
+
}
|
|
922
|
+
);
|
|
923
|
+
server.registerTool(
|
|
924
|
+
"connect",
|
|
925
|
+
{
|
|
926
|
+
description: "Grab the CDP connection to the Metro dev server. Use this if get_status shows disconnected, or to take over the connection from React Native DevTools."
|
|
927
|
+
},
|
|
928
|
+
async () => {
|
|
929
|
+
const result = await metroClient.grabConnection();
|
|
930
|
+
return { content: [{ type: "text", text: result }] };
|
|
931
|
+
}
|
|
932
|
+
);
|
|
933
|
+
server.registerTool(
|
|
934
|
+
"disconnect",
|
|
935
|
+
{
|
|
936
|
+
description: "Release the CDP connection so React Native DevTools can connect. Use this before switching to DevTools. Call connect when you want to reattach."
|
|
937
|
+
},
|
|
938
|
+
async () => {
|
|
939
|
+
metroClient.disconnect();
|
|
940
|
+
return { content: [{ type: "text", text: "Disconnected. DevTools can now connect freely." }] };
|
|
941
|
+
}
|
|
942
|
+
);
|
|
943
|
+
server.registerTool(
|
|
944
|
+
"clear_logs",
|
|
945
|
+
{
|
|
946
|
+
description: "Clear the internal log buffer. Useful after resolving an issue."
|
|
947
|
+
},
|
|
948
|
+
async () => {
|
|
949
|
+
const result = clearLogs();
|
|
950
|
+
return { content: [{ type: "text", text: result }] };
|
|
951
|
+
}
|
|
952
|
+
);
|
|
953
|
+
server.registerTool(
|
|
954
|
+
"watch_logs",
|
|
955
|
+
{
|
|
956
|
+
description: "Listen for incoming logs for a short time window and return all collected entries.",
|
|
957
|
+
inputSchema: WatchLogsSchema.shape
|
|
958
|
+
},
|
|
959
|
+
async (params) => {
|
|
960
|
+
const result = await watchLogs(params);
|
|
961
|
+
return { content: [{ type: "text", text: result }] };
|
|
962
|
+
}
|
|
963
|
+
);
|
|
964
|
+
server.registerTool(
|
|
965
|
+
"reload",
|
|
966
|
+
{
|
|
967
|
+
description: "Reload the React Native app via Metro."
|
|
968
|
+
},
|
|
969
|
+
async () => {
|
|
970
|
+
invalidateSourceMapCache();
|
|
971
|
+
const result = await reload();
|
|
972
|
+
return { content: [{ type: "text", text: result }] };
|
|
973
|
+
}
|
|
974
|
+
);
|
|
975
|
+
server.registerTool(
|
|
976
|
+
"resolve_stack",
|
|
977
|
+
{
|
|
978
|
+
description: "Resolve a stack trace from the buffer against the Metro source map, showing original file:line instead of bundle offsets. Optionally filter by error message substring.",
|
|
979
|
+
inputSchema: ResolveStackSchema.shape
|
|
980
|
+
},
|
|
981
|
+
async (params) => {
|
|
982
|
+
const result = await resolveStack(params);
|
|
983
|
+
return { content: [{ type: "text", text: result }] };
|
|
984
|
+
}
|
|
985
|
+
);
|
|
986
|
+
server.registerTool(
|
|
987
|
+
"list_devices",
|
|
988
|
+
{
|
|
989
|
+
description: "List active iOS simulators and Android emulators. Use this to find available devices before taking screenshots or sending taps."
|
|
990
|
+
},
|
|
991
|
+
async () => {
|
|
992
|
+
const result = listDevices();
|
|
993
|
+
return { content: [{ type: "text", text: result }] };
|
|
994
|
+
}
|
|
995
|
+
);
|
|
996
|
+
server.registerTool(
|
|
997
|
+
"screenshot",
|
|
998
|
+
{
|
|
999
|
+
description: "Take a screenshot of the active iOS simulator or Android emulator. Returns the image directly. Optionally specify platform ('ios' or 'android') or device_id if multiple devices are running.",
|
|
1000
|
+
inputSchema: ScreenshotSchema.shape
|
|
1001
|
+
},
|
|
1002
|
+
async (params) => {
|
|
1003
|
+
const result = screenshot(params);
|
|
1004
|
+
if (result.type === "image") {
|
|
1005
|
+
return { content: [{ type: "image", data: result.data, mimeType: result.mimeType }] };
|
|
1006
|
+
}
|
|
1007
|
+
return { content: [{ type: "text", text: result.text }] };
|
|
1008
|
+
}
|
|
1009
|
+
);
|
|
1010
|
+
server.registerTool(
|
|
1011
|
+
"tap",
|
|
1012
|
+
{
|
|
1013
|
+
description: "Tap at x,y coordinates on the active simulator/emulator. Use screenshot first to determine coordinates. iOS requires idb (brew install idb-companion && pip3 install fb-idb). Android works via adb out of the box. Optionally specify platform or device_id.",
|
|
1014
|
+
inputSchema: TapSchema.shape
|
|
1015
|
+
},
|
|
1016
|
+
async (params) => {
|
|
1017
|
+
const result = tap(params);
|
|
1018
|
+
return { content: [{ type: "text", text: result }] };
|
|
1019
|
+
}
|
|
1020
|
+
);
|
|
1021
|
+
server.registerTool(
|
|
1022
|
+
"swipe",
|
|
1023
|
+
{
|
|
1024
|
+
description: "Swipe from one coordinate to another. Requires idb on iOS (brew install idb-companion && pip3 install fb-idb). Android works via adb out of the box. Useful for scrolling lists or dismissing sheets.",
|
|
1025
|
+
inputSchema: SwipeSchema.shape
|
|
1026
|
+
},
|
|
1027
|
+
async (params) => {
|
|
1028
|
+
const result = swipe(params);
|
|
1029
|
+
return { content: [{ type: "text", text: result }] };
|
|
1030
|
+
}
|
|
1031
|
+
);
|
|
1032
|
+
async function main() {
|
|
1033
|
+
metroClient.start();
|
|
1034
|
+
const transport = new StdioServerTransport();
|
|
1035
|
+
await server.connect(transport);
|
|
1036
|
+
process.on("SIGINT", () => {
|
|
1037
|
+
metroClient.stop();
|
|
1038
|
+
process.exit(0);
|
|
1039
|
+
});
|
|
1040
|
+
process.on("SIGTERM", () => {
|
|
1041
|
+
metroClient.stop();
|
|
1042
|
+
process.exit(0);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
main().catch((err) => {
|
|
1046
|
+
process.stderr.write(`Fatal error: ${err}
|
|
1047
|
+
`);
|
|
1048
|
+
process.exit(1);
|
|
1049
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@synnode/expo-metro-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for Expo/React Native development — live logs, stack trace resolution, and simulator/emulator automation via CDP and platform CLIs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"expo-metro-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup src/index.ts --format esm --dts --clean && chmod +x dist/index.js",
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"expo",
|
|
20
|
+
"react-native",
|
|
21
|
+
"metro",
|
|
22
|
+
"simulator",
|
|
23
|
+
"emulator",
|
|
24
|
+
"ios",
|
|
25
|
+
"android",
|
|
26
|
+
"debugging",
|
|
27
|
+
"claude",
|
|
28
|
+
"ai"
|
|
29
|
+
],
|
|
30
|
+
"author": "Michael Sanders",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"homepage": "https://github.com/Synnode/expo-metro-mcp#readme",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/Synnode/expo-metro-mcp.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/Synnode/expo-metro-mcp/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
45
|
+
"source-map": "^0.7.6",
|
|
46
|
+
"ws": "^8.18.0",
|
|
47
|
+
"zod": "^3.23.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.0.0",
|
|
51
|
+
"@types/ws": "^8.18.0",
|
|
52
|
+
"tsup": "^8.5.0",
|
|
53
|
+
"tsx": "^4.19.0",
|
|
54
|
+
"typescript": "^5.7.0"
|
|
55
|
+
}
|
|
56
|
+
}
|