@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.
Files changed (3) hide show
  1. package/README.md +22 -9
  2. package/dist/index.js +16 -1043
  3. 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 node /absolute/path/to/expo-metro-mcp/dist/index.js
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 node /path/to/dist/index.js
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. The MCP server does **not** auto-reconnect, so you can freely switch between it and DevTools:
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
- 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
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
- // src/index.ts
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ ---
6
7
 
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();
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
- // src/tools/get-logs.ts
261
- import { z } from "zod";
12
+ No stack frames available.`;let o=Te(n.rawMessage);if(!o.length)return`${r}
262
13
 
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).
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
- 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
- });
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.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
  },