@synnode/expo-metro-mcp 1.0.0 → 1.0.2
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/README.md +22 -9
- package/dist/index.js +16 -1043
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,11 +7,15 @@ Uses the **Chrome DevTools Protocol (CDP)** inspector endpoint that Metro expose
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
cd expo-metro-mcp
|
|
11
|
-
npm install && npm run build
|
|
12
|
-
|
|
13
10
|
# Register with Claude Code CLI
|
|
14
|
-
claude mcp add expo-metro
|
|
11
|
+
claude mcp add expo-metro npx @synnode/expo-metro-mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or install globally and register:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @synnode/expo-metro-mcp
|
|
18
|
+
claude mcp add expo-metro expo-metro-mcp
|
|
15
19
|
```
|
|
16
20
|
|
|
17
21
|
Restart Claude Code after adding the server.
|
|
@@ -33,7 +37,7 @@ LOG_BUFFER_SIZE=1000
|
|
|
33
37
|
If Metro runs on a different port:
|
|
34
38
|
|
|
35
39
|
```bash
|
|
36
|
-
claude mcp add expo-metro --env METRO_PORT=8082
|
|
40
|
+
claude mcp add expo-metro --env METRO_PORT=8082 npx @synnode/expo-metro-mcp
|
|
37
41
|
```
|
|
38
42
|
|
|
39
43
|
## Available tools
|
|
@@ -74,11 +78,12 @@ claude mcp add expo-metro --env METRO_PORT=8082 node /path/to/dist/index.js
|
|
|
74
78
|
|
|
75
79
|
## Using alongside React Native DevTools
|
|
76
80
|
|
|
77
|
-
CDP only allows one client at a time.
|
|
81
|
+
CDP only allows one client at a time. Switching between the MCP and DevTools is seamless — whichever connects last takes over, and the other is kicked out automatically.
|
|
82
|
+
|
|
83
|
+
- **To use DevTools**: just open or reconnect it. The MCP will be disconnected automatically.
|
|
84
|
+
- **To return to MCP**: call `connect`. DevTools will lose its connection.
|
|
78
85
|
|
|
79
|
-
|
|
80
|
-
2. Open React Native DevTools as usual
|
|
81
|
-
3. When done, close DevTools and call `connect` to reattach the MCP server
|
|
86
|
+
`disconnect` is available if you want to explicitly release the connection first, but it's not required.
|
|
82
87
|
|
|
83
88
|
`get_status` always shows whether the MCP is currently connected.
|
|
84
89
|
|
|
@@ -86,6 +91,14 @@ CDP only allows one client at a time. The MCP server does **not** auto-reconnect
|
|
|
86
91
|
|
|
87
92
|
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
93
|
|
|
94
|
+
## Teaching Claude Code about this MCP
|
|
95
|
+
|
|
96
|
+
Add [`SKILL.md`](./SKILL.md) to your project root (or `CLAUDE.md`) to teach Claude Code how to use this MCP effectively — when to check logs, how to debug errors, how to use screenshots and taps, and more.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
curl -o SKILL.md https://raw.githubusercontent.com/Synnode/expo-metro-mcp/master/SKILL.md
|
|
100
|
+
```
|
|
101
|
+
|
|
89
102
|
## Notes
|
|
90
103
|
|
|
91
104
|
- If Metro is not reachable on startup: the server starts normally, `get_status` returns `connected: false`. Call `connect` once your dev server is up.
|
package/dist/index.js
CHANGED
|
@@ -1,1049 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import{McpServer as Fe}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as We}from"@modelcontextprotocol/sdk/server/stdio.js";import $ from"ws";import se from"http";var ce=parseInt(process.env.METRO_PORT??"8081",10),ae=process.env.METRO_HOST??"localhost",le=parseInt(process.env.LOG_BUFFER_SIZE??"1000",10),ue=3e4,C=1e3,me={log:"log",info:"info",warning:"warn",warn:"warn",error:"error",debug:"debug",dir:"log",dirxml:"log",table:"log",assert:"error"};function pe(e){return e.map(t=>t.value!==void 0&&t.value!==null?String(t.value):t.description?t.description:"").filter(Boolean).join(" ")}async function de(e,t){return new Promise(n=>{let r=se.get(`http://${e}:${t}/json/list`,o=>{let i="";o.on("data",s=>i+=s),o.on("end",()=>{try{let s=JSON.parse(i);Array.isArray(s)?n(s):n([])}catch{n([])}})});r.on("error",()=>n([])),r.setTimeout(2e3,()=>{r.destroy(),n([])})})}var T=class{buffer=[];cdpWs=null;eventsWs=null;_connected=!1;_currentTargetId=null;_lastConnectedAt=null;_totalReceived=0;_stopped=!1;_eventsBackoff=C;_deviceTitle=null;host=ae;port=ce;get connected(){return this._connected}get lastConnectedAt(){return this._lastConnectedAt}get totalReceived(){return this._totalReceived}get bufferedEntries(){return this.buffer.length}get deviceTitle(){return this._deviceTitle}start(){this._stopped=!1,this.connectEvents()}stop(){this._stopped=!0,this.cdpWs&&(this.cdpWs.terminate(),this.cdpWs=null),this.eventsWs&&(this.eventsWs.terminate(),this.eventsWs=null)}disconnect(){this.cdpWs&&(this.cdpWs.terminate(),this.cdpWs=null),this._connected=!1,this._currentTargetId=null,this._deviceTitle=null}getEntries(t={}){let n=this.buffer;t.since!==void 0&&(n=n.filter(o=>o.timestamp>=t.since)),t.level&&(n=n.filter(o=>o.level===t.level));let r=t.lines??50;return n.slice(-r)}clearBuffer(){let t=this.buffer.length;return this.buffer=[],t}async grabConnection(){return await this.checkForNewTarget(),await new Promise(t=>setTimeout(t,500)),this._connected?`Connected to ${this._deviceTitle??"device"}.`:"No device found. Is Metro running with a connected device?"}addEntry(t){this._totalReceived++,this.buffer.push(t),this.buffer.length>le&&this.buffer.shift()}async checkForNewTarget(){let t=await de(this.host,this.port);if(!t.length){this._connected&&(this._connected=!1,this._currentTargetId=null,this._deviceTitle=null);return}let n=t[0];n.id===this._currentTargetId&&this.cdpWs?.readyState===$.OPEN||this.connectCdp(n)}connectCdp(t){this.cdpWs&&(this.cdpWs.terminate(),this.cdpWs=null),this._currentTargetId=t.id,this._deviceTitle=t.title??null;let n=new $(t.webSocketDebuggerUrl);this.cdpWs=n,n.on("open",()=>{this._connected=!0,this._lastConnectedAt=new Date,n.send(JSON.stringify({id:1,method:"Runtime.enable",params:{}}))}),n.on("message",r=>{let o;try{o=JSON.parse(r.toString())}catch{return}o.method==="Runtime.consoleAPICalled"&&o.params&&this.handleConsoleEvent(o.params)}),n.on("close",()=>{this.cdpWs===n&&(this._connected=!1,this.cdpWs=null,this._currentTargetId=null,this._deviceTitle=null)}),n.on("error",()=>{})}handleConsoleEvent(t){let n=typeof t.type=="string"?t.type:"log",r=me[n]??"log",o=Array.isArray(t.args)?t.args:[],i=pe(o),s=typeof t.timestamp=="number"?Math.round(t.timestamp):Date.now();if(!i)return;let a=t.stackTrace?.callFrames?.filter(f=>f.url&&!f.url.startsWith("native")).map(f=>({functionName:f.functionName??"(anonymous)",url:f.url,line:f.lineNumber??0,col:f.columnNumber??0})),d=i.includes("http://")?i:void 0;this.addEntry({timestamp:s,level:r,message:i,rawMessage:d,rawFrames:a?.length?a:void 0})}connectEvents(){if(this._stopped)return;let t=`ws://${this.host}:${this.port}/events`,n;try{n=new $(t)}catch{this.scheduleEventsReconnect();return}this.eventsWs=n,n.on("open",()=>{this._eventsBackoff=C}),n.on("message",r=>{let o=null;try{o=JSON.parse(r.toString())}catch{return}(o.type==="build_failed"||o.type==="bundling_error")&&this.addEntry({timestamp:Date.now(),level:"error",message:o.message??o.type})}),n.on("close",()=>{this.eventsWs===n&&(this.eventsWs=null,this.scheduleEventsReconnect())}),n.on("error",()=>{})}scheduleEventsReconnect(){this._stopped||setTimeout(()=>{this._eventsBackoff=Math.min(this._eventsBackoff*2,ue),this.connectEvents()},this._eventsBackoff)}},c=new T;import{z as x}from"zod";var fe=/\(https?:\/\/[^)]+\.bundle[^)]*:(\d+:\d+)\)/g;function b(e){return e.replace(fe,"(:$1)")}function y(e){let t=new Date(e),n=String(t.getHours()).padStart(2,"0"),r=String(t.getMinutes()).padStart(2,"0"),o=String(t.getSeconds()).padStart(2,"0");return`${n}:${r}:${o}`}var O=x.object({lines:x.coerce.number().int().min(1).max(500).optional().default(50),level:x.enum(["error","warn","info","log","debug"]).optional(),since:x.string().optional()});function ge(e){let t=Number(e);if(!isNaN(t)&&t>1e9)return t<1e12?t*1e3:t;let n=e.match(/^(\d+(?:\.\d+)?)(s|m|h)$/);if(n){let r=parseFloat(n[1]),o=n[2],i={s:1e3,m:6e4,h:36e5};return Date.now()-r*i[o]}return t}function L(e){let t=e.since?ge(e.since):void 0,n=c.getEntries({lines:e.lines,level:e.level,since:t});return n.length===0?"No log entries found.":n.map(r=>`[${y(r.timestamp)}] [${r.level.toUpperCase()}] ${b(r.message)}`).join(`
|
|
3
|
+
`)}import{z as A}from"zod";var N=A.object({lines:A.coerce.number().int().min(1).max(200).optional().default(20)});function D(e){let t=c.getEntries({level:"error",lines:e.lines});return t.length===0?"No errors in buffer.":t.map(n=>`[${y(n.timestamp)}] [ERROR]
|
|
4
|
+
${b(n.message)}`).join(`
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
---
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
import
|
|
9
|
-
|
|
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();
|
|
8
|
+
`)}function I(){let e={connected:c.connected,host:c.host,port:c.port,device:c.deviceTitle,buffered_entries:c.bufferedEntries,last_connected_at:c.lastConnectedAt?.toISOString()??null,total_received:c.totalReceived,expo_sdk_version:null};return JSON.stringify(e,null,2)}function P(){let e=c.clearBuffer();return`Cleared ${e} log ${e===1?"entry":"entries"} from the buffer.`}import{z as k}from"zod";var F=k.object({duration:k.string().optional().default("10s"),level:k.enum(["error","warn","info","log","debug"]).optional()});function he(e){let t=e.match(/^(\d+(?:\.\d+)?)(s|m)$/);if(!t)return 1e4;let n=parseFloat(t[1]),o=t[2]==="m"?n*6e4:n*1e3;return Math.min(o,3e4)}var ve=500;async function W(e){if(!c.connected)return"Metro is not connected. Start Expo dev server and try again.";let t=he(e.duration),n=c.bufferedEntries,r=Date.now(),o=e.level;await new Promise(a=>{let d=setInterval(()=>{Date.now()-r>=t&&(clearInterval(d),a())},ve)});let s=c.getEntries({lines:c.bufferedEntries}).slice(n),l=o?s.filter(a=>a.level===o):s;return l.length===0?`No logs received during ${e.duration} window.`:l.map(a=>`[${y(a.timestamp)}] [${a.level.toUpperCase()}] ${b(a.message)}`).join(`
|
|
9
|
+
`)}import be from"http";var ye=parseInt(process.env.METRO_PORT??"8081",10),Se=process.env.METRO_HOST??"localhost";async function B(){return new Promise(e=>{let t=be.request({hostname:Se,port:ye,path:"/reload",method:"POST"},n=>{n.resume(),n.statusCode===200?e("App reloaded."):e(`Reload failed: HTTP ${n.statusCode}`)});t.on("error",n=>e(`Reload failed: ${n.message}`)),t.setTimeout(3e3,()=>{t.destroy(),e("Reload failed: timeout")}),t.end()})}import{z as j}from"zod";import we from"http";import{SourceMapConsumer as xe}from"source-map";var G=j.object({message:j.string().optional()}),_=new Map,_e=6e4;function U(e,t,n){return e.replace(/^https?:\/\/[^/]+/,`http://${t}:${n}`)}function z(e){let t=e.match(/(?:\/\/&|\?)(.+)$/),n=t?t[1]:"dev=true&minify=false";return`${e.replace(/(\/[^/?]+)\.bundle.*$/,"$1.map")}?${n}`}async function $e(e){let t=_.get(e);return t&&Date.now()-t.fetchedAt<_e?t.consumer:new Promise(n=>{let r=new URL(e),o=we.get({hostname:r.hostname,port:Number(r.port)||8081,path:r.pathname+r.search},i=>{let s=[];i.on("data",l=>s.push(l)),i.on("end",async()=>{if(i.statusCode!==200){n(null);return}try{let l=JSON.parse(Buffer.concat(s).toString()),a=await xe.with(l,null,d=>d);_.set(e,{consumer:a,fetchedAt:Date.now()}),n(a)}catch{n(null)}})});o.on("error",()=>n(null)),o.setTimeout(1e4,()=>{o.destroy(),n(null)})})}function H(){_.forEach(e=>e.consumer.destroy()),_.clear()}function Te(e){let t=/at\s+([\w$.<>[\] ]+?)\s+\((https?:\/\/[^)]+\.bundle[^)]*):(\d+):(\d+)\)/g,n=[],r;for(;(r=t.exec(e))!==null;)n.push({functionName:r[1].trim(),url:r[2],line:parseInt(r[3])-1,col:parseInt(r[4])});return n}async function ke(e,t){let n=[],r=new Map;for(let i of e){let s=U(i.url,c.host,c.port),l=z(s);r.has(l)||r.set(l,[]),r.get(l).push(i)}let o=new Map;await Promise.all([...r.keys()].map(async i=>{o.set(i,await $e(i))}));for(let i of e){let s=U(i.url,c.host,c.port),l=z(s),a=o.get(l)??null;if(a){let d=a.originalPositionFor({line:i.line+1,column:i.col});if(d.source){let f=d.source.replace(/^.*\/\/\//,"").replace(/\?.*$/,"");n.push(` at ${i.functionName} (${f}:${d.line}:${d.column})`);continue}}n.push(` at ${i.functionName} (:${i.line+1}:${i.col})`)}return n}async function J(e){let t=c.getEntries({level:"error",lines:50}),n=e.message?[...t].reverse().find(a=>a.message.includes(e.message)):t.at(-1);if(!n)return"No error entries in buffer.";let r=`[ERROR] ${n.message.split(`
|
|
10
|
+
`)[0]}`;if(!n.rawMessage)return`${r}
|
|
259
11
|
|
|
260
|
-
|
|
261
|
-
import { z } from "zod";
|
|
12
|
+
No stack frames available.`;let o=Te(n.rawMessage);if(!o.length)return`${r}
|
|
262
13
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
function
|
|
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).
|
|
14
|
+
No stack frames available.`;let i=await ke(o),s=i.filter(a=>!a.includes("node_modules")&&!a.includes("(:")),l=[r,""];return s.length?l.push(...s):l.push(...i),l.join(`
|
|
15
|
+
`)}import{z as E}from"zod";import{execSync as h}from"child_process";import*as v from"fs";import*as Y from"os";import*as Z from"path";import{execSync as q}from"child_process";function X(e){try{return q(e,{timeout:5e3,stdio:["ignore","pipe","ignore"]}).toString().trim()}catch{return""}}function K(e){try{return q(`which ${e}`,{timeout:2e3,stdio:"ignore"}),!0}catch{return!1}}function Ee(){if(!K("xcrun"))return[];let e=X("xcrun simctl list devices booted --json");if(!e)return[];try{let t=JSON.parse(e),n=[];for(let[,r]of Object.entries(t.devices))for(let o of r)o.state==="Booted"&&n.push({id:o.udid,name:o.name,platform:"ios"});return n}catch{return[]}}function Me(){if(!K("adb"))return[];let e=X("adb devices -l");if(!e)return[];let t=[],n=e.split(`
|
|
16
|
+
`).slice(1);for(let r of n){let o=r.trim().split(/\s+/);if(o.length<2||o[1]!=="device")continue;let i=o[0],s=r.match(/model:(\S+)/),l=s?s[1].replace(/_/g," "):i;t.push({id:i,name:l,platform:"android"})}return t}function g(){return[...Ee(),...Me()]}function w(e,t){let n=g();return n.length?t?n.find(r=>r.id===t||r.name.toLowerCase().includes(t.toLowerCase()))??null:e==="ios"?n.find(r=>r.platform==="ios")??null:e==="android"?n.find(r=>r.platform==="android")??null:n.find(r=>r.platform==="ios")??n[0]:null}var V=E.object({device_id:E.string().optional(),platform:E.enum(["ios","android"]).optional()});function Re(e){try{let t=h(`sips -g pixelWidth -g pixelHeight "${e}"`,{timeout:5e3,stdio:["ignore","pipe","ignore"]}).toString(),n=t.match(/pixelWidth:\s*(\d+)/),r=t.match(/pixelHeight:\s*(\d+)/);if(!n||!r)return 1;let o=parseInt(n[1]),i=parseInt(r[1]),s=Math.max(o,i);return s>=2500&&o%3===0||s>=2500?3:s>=1334?2:1}catch{return 1}}function Ce(e,t){h(`xcrun simctl io "${e}" screenshot "${t}"`,{timeout:1e4,stdio:["ignore","ignore","pipe"]});let n=Re(t);if(n>1)try{let r=h(`sips -g pixelWidth -g pixelHeight "${t}"`,{timeout:5e3,stdio:["ignore","pipe","ignore"]}).toString(),o=r.match(/pixelWidth:\s*(\d+)/),i=r.match(/pixelHeight:\s*(\d+)/);if(o&&i){let s=Math.round(parseInt(o[1])/n),l=Math.round(parseInt(i[1])/n),a=t.replace(".png","-points.png");h(`sips -z ${l} ${s} "${t}" --out "${a}"`,{timeout:1e4,stdio:"ignore"}),v.renameSync(a,t)}}catch{}}function Oe(e,t){let n=`/sdcard/mcp_screenshot_${Date.now()}.png`;h(`adb -s "${e}" shell screencap -p "${n}"`,{timeout:1e4,stdio:["ignore","ignore","pipe"]}),h(`adb -s "${e}" pull "${n}" "${t}"`,{timeout:1e4,stdio:["ignore","ignore","pipe"]}),h(`adb -s "${e}" shell rm "${n}"`,{timeout:5e3,stdio:"ignore"})}function Le(e){if(e.length<24||e.readUInt32BE(0)!==2303741511)return null;let t=e.readUInt32BE(16),n=e.readUInt32BE(20);return{width:t,height:n}}function Q(e){let t=g();if(!t.length)return{type:"text",text:"No active simulators or emulators found. Start a simulator (iOS) or emulator (Android) first."};let n=w(e.platform,e.device_id);if(!n)return{type:"text",text:`No matching device found. Available: ${t.map(o=>`${o.name} (${o.platform})`).join(", ")}`};let r=Z.join(Y.tmpdir(),`expo-mcp-screenshot-${Date.now()}.png`);try{n.platform==="ios"?Ce(n.id,r):Oe(n.id,r);let o=v.readFileSync(r),i=o.toString("base64"),s=Le(o);return v.unlinkSync(r),{type:"image",data:i,mimeType:"image/png",width:s?.width??0,height:s?.height??0}}catch(o){try{v.unlinkSync(r)}catch{}return{type:"text",text:`Screenshot failed: ${o instanceof Error?o.message:String(o)}`}}}import{z as m}from"zod";import{execSync as S}from"child_process";import{spawn as Ae}from"child_process";var p=null,M=null;function R(e){p&&!p.killed&&M===e||(p&&!p.killed&&p.kill(),p=Ae("idb_companion",["--udid",e],{detached:!1,stdio:"ignore"}),M=e,p.on("exit",()=>{p=null,M=null}),Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,800))}process.on("exit",()=>p?.kill());process.on("SIGTERM",()=>{p?.kill(),process.exit(0)});process.on("SIGINT",()=>{p?.kill(),process.exit(0)});var ee=m.object({x:m.number().int().describe("X coordinate in points/pixels"),y:m.number().int().describe("Y coordinate in points/pixels"),device_id:m.string().optional(),platform:m.enum(["ios","android"]).optional()}),te=m.object({x1:m.number().int(),y1:m.number().int(),x2:m.number().int(),y2:m.number().int(),duration_ms:m.number().int().min(50).max(5e3).optional().default(300),device_id:m.string().optional(),platform:m.enum(["ios","android"]).optional()});function ne(){try{return S("which idb",{timeout:2e3,stdio:"ignore"}),!0}catch{return!1}}function Ne(e,t,n){if(ne()){R(e),S(`idb ui tap ${t} ${n} --udid "${e}"`,{timeout:5e3,stdio:["ignore","ignore","pipe"]});return}let r=JSON.stringify({touches:[{x:t,y:n,action:"began"}]});try{S(`echo '${r}' | xcrun simctl io "${e}" sendtouchJSON -`,{timeout:5e3,stdio:["pipe","ignore","pipe"]});return}catch{}throw new Error(`iOS tap requires idb (Facebook iOS Development Bridge).
|
|
800
17
|
Install via: brew install idb-companion && pip3 install fb-idb
|
|
801
|
-
Or: brew install facebook/fb/idb-companion`
|
|
802
|
-
);
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
});
|
|
18
|
+
Or: brew install facebook/fb/idb-companion`)}function De(e,t,n){S(`adb -s "${e}" shell input tap ${t} ${n}`,{timeout:5e3,stdio:["ignore","ignore","pipe"]})}function Ie(e,t,n,r,o,i){if(ne()){R(e);let s=(i/1e3).toFixed(2);S(`idb ui swipe ${t} ${n} ${r} ${o} ${s} --udid "${e}"`,{timeout:i+5e3,stdio:["ignore","ignore","pipe"]});return}throw new Error(`iOS swipe requires idb (Facebook iOS Development Bridge).
|
|
19
|
+
Install via: brew install idb-companion && pip3 install fb-idb`)}function Pe(e,t,n,r,o,i){S(`adb -s "${e}" shell input swipe ${t} ${n} ${r} ${o} ${i}`,{timeout:1e4,stdio:["ignore","ignore","pipe"]})}function re(e){let t=g();if(!t.length)return"No active simulators or emulators found.";let n=w(e.platform,e.device_id);if(!n)return`No matching device found. Available: ${t.map(r=>`${r.name} (${r.platform})`).join(", ")}`;try{return n.platform==="ios"?Ne(n.id,e.x,e.y):De(n.id,e.x,e.y),`Tapped (${e.x}, ${e.y}) on ${n.name} [${n.platform}].`}catch(r){return`Tap failed: ${r instanceof Error?r.message:String(r)}`}}function oe(e){let t=g();if(!t.length)return"No active simulators or emulators found.";let n=w(e.platform,e.device_id);if(!n)return`No matching device found. Available: ${t.map(r=>`${r.name} (${r.platform})`).join(", ")}`;try{return n.platform==="ios"?(Ie(n.id,e.x1,e.y1,e.x2,e.y2,e.duration_ms),`Swiped from (${e.x1}, ${e.y1}) to (${e.x2}, ${e.y2}) on ${n.name} [ios]. Note: iOS swipe simulation is limited \u2014 use Android for reliable swipe gestures.`):(Pe(n.id,e.x1,e.y1,e.x2,e.y2,e.duration_ms),`Swiped from (${e.x1}, ${e.y1}) to (${e.x2}, ${e.y2}) on ${n.name} [android] over ${e.duration_ms}ms.`)}catch(r){return`Swipe failed: ${r instanceof Error?r.message:String(r)}`}}function ie(){let e=g();if(!e.length)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>`";let t=e.map(n=>`- ${n.name} [${n.platform}] id: ${n.id}`);return`Active devices (${e.length}):
|
|
20
|
+
${t.join(`
|
|
21
|
+
`)}`}var u=new Fe({name:"expo-metro-mcp",version:"0.1.0"});u.registerTool("get_logs",{description:"Fetch recent logs from the Metro dev server buffer. Supports filtering by level and time.",inputSchema:O.shape},async e=>({content:[{type:"text",text:L(e)}]}));u.registerTool("get_errors",{description:"Fetch recent errors from the Metro dev server buffer, with stack traces.",inputSchema:N.shape},async e=>({content:[{type:"text",text:D(e)}]}));u.registerTool("get_status",{description:"Check the connection status of the Metro dev server and buffer statistics."},async()=>({content:[{type:"text",text:I()}]}));u.registerTool("connect",{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."},async()=>({content:[{type:"text",text:await c.grabConnection()}]}));u.registerTool("disconnect",{description:"Release the CDP connection so React Native DevTools can connect. Use this before switching to DevTools. Call connect when you want to reattach."},async()=>(c.disconnect(),{content:[{type:"text",text:"Disconnected. DevTools can now connect freely."}]}));u.registerTool("clear_logs",{description:"Clear the internal log buffer. Useful after resolving an issue."},async()=>({content:[{type:"text",text:P()}]}));u.registerTool("watch_logs",{description:"Listen for incoming logs for a short time window and return all collected entries.",inputSchema:F.shape},async e=>({content:[{type:"text",text:await W(e)}]}));u.registerTool("reload",{description:"Reload the React Native app via Metro."},async()=>(H(),{content:[{type:"text",text:await B()}]}));u.registerTool("resolve_stack",{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.",inputSchema:G.shape},async e=>({content:[{type:"text",text:await J(e)}]}));u.registerTool("list_devices",{description:"List active iOS simulators and Android emulators. Use this to find available devices before taking screenshots or sending taps."},async()=>({content:[{type:"text",text:ie()}]}));u.registerTool("screenshot",{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.",inputSchema:V.shape},async e=>{let t=Q(e);if(t.type==="image"){let n=t.width&&t.height?`Screenshot dimensions: ${t.width}x${t.height}px. Use these exact coordinates for tap and swipe \u2014 no scaling needed.`:"Screenshot dimensions unknown.";return{content:[{type:"image",data:t.data,mimeType:t.mimeType},{type:"text",text:n}]}}return{content:[{type:"text",text:t.text}]}});u.registerTool("tap",{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.",inputSchema:ee.shape},async e=>({content:[{type:"text",text:re(e)}]}));u.registerTool("swipe",{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.",inputSchema:te.shape},async e=>({content:[{type:"text",text:oe(e)}]}));async function Be(){c.start();let e=new We;await u.connect(e),process.on("SIGINT",()=>{c.stop(),process.exit(0)}),process.on("SIGTERM",()=>{c.stop(),process.exit(0)})}Be().catch(e=>{process.stderr.write(`Fatal error: ${e}
|
|
22
|
+
`),process.exit(1)});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synnode/expo-metro-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "MCP server for Expo/React Native development — live logs, stack trace resolution, and simulator/emulator automation via CDP and platform CLIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
|
-
"build": "tsup src/index.ts --format esm --dts --clean && chmod +x dist/index.js",
|
|
13
|
+
"build": "tsup src/index.ts --format esm --dts --clean --minify && chmod +x dist/index.js",
|
|
14
14
|
"dev": "tsx src/index.ts",
|
|
15
15
|
"typecheck": "tsc --noEmit"
|
|
16
16
|
},
|