metro-mcp 0.6.2 → 0.6.4

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.
@@ -3317,6 +3317,7 @@ function mergeConfig(target, source) {
3317
3317
  // src/server.ts
3318
3318
  import { exec } from "child_process";
3319
3319
  import { promisify } from "util";
3320
+ import fs4 from "fs";
3320
3321
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3321
3322
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3322
3323
  import { z as z23 } from "zod";
@@ -3740,7 +3741,7 @@ function extractCDPExceptionMessage(details, fallback = "Evaluation failed") {
3740
3741
  // package.json
3741
3742
  var package_default = {
3742
3743
  name: "metro-mcp",
3743
- version: "0.6.2",
3744
+ version: "0.6.4",
3744
3745
  description: "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
3745
3746
  homepage: "https://metromcp.dev",
3746
3747
  repository: {
@@ -3941,45 +3942,103 @@ class DeviceBufferManager {
3941
3942
  }
3942
3943
 
3943
3944
  // src/plugins/console.ts
3945
+ function formatPreview(preview) {
3946
+ if (!preview.properties)
3947
+ return null;
3948
+ const props = preview.properties.map((p) => {
3949
+ if (p.type === "object" && p.valuePreview) {
3950
+ const nested = formatPreview(p.valuePreview);
3951
+ return `${p.name}: ${nested || p.value || "[object]"}`;
3952
+ }
3953
+ return `${p.name}: ${p.value}`;
3954
+ });
3955
+ const overflow = preview.overflow ? ", ..." : "";
3956
+ if (preview.subtype === "array")
3957
+ return `[${props.join(", ")}${overflow}]`;
3958
+ return `{${props.join(", ")}${overflow}}`;
3959
+ }
3960
+ function formatRemoteObject(obj) {
3961
+ if (obj.type === "string")
3962
+ return obj.value;
3963
+ if (obj.type === "number")
3964
+ return String(obj.value);
3965
+ if (obj.type === "boolean")
3966
+ return String(obj.value);
3967
+ if (obj.type === "undefined")
3968
+ return "undefined";
3969
+ if (obj.subtype === "null")
3970
+ return "null";
3971
+ if (obj.preview) {
3972
+ const formatted = formatPreview(obj.preview);
3973
+ if (formatted)
3974
+ return formatted;
3975
+ }
3976
+ if (obj.description)
3977
+ return obj.description;
3978
+ if (obj.value !== undefined)
3979
+ return JSON.stringify(obj.value);
3980
+ return obj.className || obj.type || "[object]";
3981
+ }
3944
3982
  function formatCDPArgs(args) {
3945
3983
  return args.map((arg) => {
3946
3984
  if (typeof arg === "object" && arg !== null) {
3947
- const remoteObj = arg;
3948
- if (remoteObj.type === "string")
3949
- return remoteObj.value;
3950
- if (remoteObj.type === "number")
3951
- return String(remoteObj.value);
3952
- if (remoteObj.type === "boolean")
3953
- return String(remoteObj.value);
3954
- if (remoteObj.type === "undefined")
3955
- return "undefined";
3956
- if (remoteObj.subtype === "null")
3957
- return "null";
3958
- if (remoteObj.description)
3959
- return remoteObj.description;
3960
- if (remoteObj.value !== undefined)
3961
- return JSON.stringify(remoteObj.value);
3962
- return remoteObj.className || remoteObj.type || "[object]";
3985
+ return formatRemoteObject(arg);
3963
3986
  }
3964
3987
  return String(arg);
3965
3988
  }).join(" ");
3966
3989
  }
3990
+ async function resolveRemoteObject(cdpSend, objectId) {
3991
+ try {
3992
+ const result = await cdpSend("Runtime.callFunctionOn", {
3993
+ objectId,
3994
+ functionDeclaration: 'function() { try { return JSON.stringify(this, null, 2); } catch(e) { return "[unserializable]"; } }',
3995
+ returnByValue: true
3996
+ });
3997
+ const inner = result.result;
3998
+ return inner?.value ? inner.value : null;
3999
+ } catch {
4000
+ return null;
4001
+ }
4002
+ }
4003
+ async function formatCDPArgsDeep(cdpSend, args) {
4004
+ const parts = await Promise.all(args.map(async (arg) => {
4005
+ if (typeof arg !== "object" || arg === null)
4006
+ return String(arg);
4007
+ const remoteObj = arg;
4008
+ if (remoteObj.objectId && remoteObj.type === "object") {
4009
+ const deep = await resolveRemoteObject(cdpSend, remoteObj.objectId);
4010
+ if (deep)
4011
+ return deep;
4012
+ }
4013
+ return formatRemoteObject(remoteObj);
4014
+ }));
4015
+ return parts.join(" ");
4016
+ }
3967
4017
  var consolePlugin = definePlugin({
3968
4018
  name: "console",
3969
4019
  description: "Console log collection and filtering",
3970
4020
  async setup(ctx) {
3971
4021
  const buffers = new DeviceBufferManager(500);
4022
+ const cdpSend = ctx.cdp.send.bind(ctx.cdp);
3972
4023
  ctx.cdp.on("Runtime.consoleAPICalled", (params) => {
3973
4024
  const key = ctx.getActiveDeviceKey();
3974
4025
  if (!key)
3975
4026
  return;
3976
4027
  const args = params.args || [];
3977
- buffers.getOrCreate(key).push({
4028
+ const entry = {
3978
4029
  timestamp: Date.now(),
3979
4030
  level: params.type,
3980
4031
  message: formatCDPArgs(args),
3981
4032
  stackTrace: params.stackTrace ? JSON.stringify(params.stackTrace.callFrames) : undefined
3982
- });
4033
+ };
4034
+ buffers.getOrCreate(key).push(entry);
4035
+ const hasResolvable = args.some((arg) => typeof arg === "object" && arg !== null && arg.objectId);
4036
+ if (hasResolvable) {
4037
+ formatCDPArgsDeep(cdpSend, args).then((deep) => {
4038
+ if (deep !== entry.message)
4039
+ entry.message = deep;
4040
+ }).catch(() => {});
4041
+ }
3983
4042
  });
3984
4043
  ctx.events.on("bundle_transform_progressed", (event) => {
3985
4044
  if (event.transformedFileCount === 1) {
@@ -8591,9 +8650,10 @@ var inspectPointPlugin = definePlugin({
8591
8650
  });
8592
8651
 
8593
8652
  // src/plugins/devtools.ts
8594
- import { spawn as spawn2 } from "child_process";
8653
+ import fs3 from "fs";
8595
8654
  import { z as z22 } from "zod";
8596
8655
  var logger6 = createLogger("devtools");
8656
+ var DEVTOOLS_STATE_FILE = "/tmp/metro-mcp-devtools.json";
8597
8657
  async function findBrowserPath() {
8598
8658
  try {
8599
8659
  const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
@@ -8609,6 +8669,43 @@ async function findBrowserPath() {
8609
8669
  } catch {}
8610
8670
  return null;
8611
8671
  }
8672
+ async function tryFocusExisting(frontendUrl) {
8673
+ try {
8674
+ const state = JSON.parse(fs3.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
8675
+ if (!state.pid || !state.remoteDebuggingPort)
8676
+ return false;
8677
+ try {
8678
+ process.kill(state.pid, 0);
8679
+ } catch {
8680
+ return false;
8681
+ }
8682
+ const resp = await fetch(`http://localhost:${state.remoteDebuggingPort}/json`, {
8683
+ signal: AbortSignal.timeout(1000)
8684
+ });
8685
+ if (!resp.ok)
8686
+ return false;
8687
+ const targets = await resp.json();
8688
+ const target = targets.find((t) => t.url?.includes("rn_fusebox") || t.url === frontendUrl);
8689
+ if (!target?.id)
8690
+ return false;
8691
+ const activate = await fetch(`http://localhost:${state.remoteDebuggingPort}/json/activate/${target.id}`, { signal: AbortSignal.timeout(1000) });
8692
+ return activate.ok;
8693
+ } catch {
8694
+ return false;
8695
+ }
8696
+ }
8697
+ async function launchDevTools(frontendUrl) {
8698
+ const { launch: launch2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
8699
+ const chrome2 = await launch2({
8700
+ chromeFlags: [`--app=${frontendUrl}`, "--window-size=1200,600"]
8701
+ });
8702
+ chrome2.process.unref();
8703
+ try {
8704
+ fs3.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
8705
+ } catch (err) {
8706
+ logger6.warn("Failed to write devtools state:", err);
8707
+ }
8708
+ }
8612
8709
  var devtoolsPlugin = definePlugin({
8613
8710
  name: "devtools",
8614
8711
  description: "Open React Native DevTools via the CDP proxy",
@@ -8630,11 +8727,13 @@ var devtoolsPlugin = definePlugin({
8630
8727
  const browserPath = await findBrowserPath();
8631
8728
  if (browserPath) {
8632
8729
  try {
8633
- const child = spawn2(browserPath, [`--app=${frontendUrl}`, "--window-size=1200,600"], { detached: true, stdio: "ignore" });
8634
- child.unref();
8730
+ const focused = await tryFocusExisting(frontendUrl);
8731
+ if (!focused) {
8732
+ await launchDevTools(frontendUrl);
8733
+ }
8635
8734
  return { opened: true, url: frontendUrl };
8636
8735
  } catch (err) {
8637
- logger6.debug("Failed to launch browser:", err);
8736
+ logger6.debug("Failed to open DevTools:", err);
8638
8737
  }
8639
8738
  } else {
8640
8739
  logger6.debug("No Chrome/Edge installation found");
@@ -8686,20 +8785,34 @@ class CDPProxy {
8686
8785
  return this._port;
8687
8786
  }
8688
8787
  async start(port = 0) {
8689
- return new Promise((resolve, reject) => {
8690
- this.httpServer = http.createServer((req, res) => {
8691
- this.handleHttpRequest(req, res);
8788
+ const tryPort = (p) => new Promise((resolve, reject) => {
8789
+ const httpServer = http.createServer((req, res) => this.handleHttpRequest(req, res));
8790
+ httpServer.on("error", (err) => {
8791
+ httpServer.close();
8792
+ reject(err);
8692
8793
  });
8693
- this.wss = new WebSocketServer({ server: this.httpServer });
8694
- this.wss.on("connection", (ws) => this.handleNewClient(ws));
8695
- this.httpServer.on("error", reject);
8696
- this.httpServer.listen(port, () => {
8697
- const addr = this.httpServer.address();
8698
- this._port = typeof addr === "object" && addr ? addr.port : port;
8699
- logger7.info(`CDP proxy listening on port ${this._port}`);
8700
- resolve(this._port);
8794
+ httpServer.listen(p, () => {
8795
+ const addr = httpServer.address();
8796
+ const actualPort = typeof addr === "object" && addr ? addr.port : p;
8797
+ const wss = new WebSocketServer({ server: httpServer });
8798
+ wss.on("connection", (ws) => this.handleNewClient(ws));
8799
+ this.httpServer = httpServer;
8800
+ this.wss = wss;
8801
+ this._port = actualPort;
8802
+ logger7.info(`CDP proxy listening on port ${actualPort}`);
8803
+ resolve(actualPort);
8701
8804
  });
8702
8805
  });
8806
+ if (port !== 0) {
8807
+ try {
8808
+ return await tryPort(port);
8809
+ } catch (err) {
8810
+ if (err.code !== "EADDRINUSE")
8811
+ throw err;
8812
+ logger7.debug(`Preferred proxy port ${port} in use, falling back to auto-assign`);
8813
+ }
8814
+ }
8815
+ return tryPort(0);
8703
8816
  }
8704
8817
  async stop() {
8705
8818
  for (const client of this.clients.values()) {
@@ -9110,6 +9223,61 @@ async function startServer(config) {
9110
9223
  logger8.error(`Failed to initialize plugin ${plugin.name}:`, err);
9111
9224
  }
9112
9225
  }
9226
+ const PROXY_LOCK_FILE = "/tmp/metro-mcp-proxy.json";
9227
+ let isPrimaryInstance = false;
9228
+ async function tryConnectViaProxy() {
9229
+ try {
9230
+ const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9231
+ if (lockData.pid && lockData.port) {
9232
+ try {
9233
+ process.kill(lockData.pid, 0);
9234
+ } catch {
9235
+ return false;
9236
+ }
9237
+ const resp = await fetch(`http://127.0.0.1:${lockData.port}/json`, {
9238
+ signal: AbortSignal.timeout(2000)
9239
+ });
9240
+ if (!resp.ok)
9241
+ return false;
9242
+ const targets = await resp.json();
9243
+ if (targets.length > 0 && targets[0].webSocketDebuggerUrl) {
9244
+ logger8.info(`Found existing metro-mcp proxy (PID ${lockData.pid}, port ${lockData.port}) — connecting as secondary`);
9245
+ await cdpClient.connect(targets[0]);
9246
+ if (lockData.metroPort) {
9247
+ eventsClient.connect(config.metro.host, lockData.metroPort);
9248
+ config.metro.port = lockData.metroPort;
9249
+ }
9250
+ config.proxy = {
9251
+ ...config.proxy,
9252
+ port: lockData.port
9253
+ };
9254
+ activeDeviceKey = targets[0].id ? `${lockData.port}-${targets[0].id}` : null;
9255
+ activeDeviceName = targets[0].title || targets[0].id || "secondary";
9256
+ return true;
9257
+ }
9258
+ }
9259
+ } catch {}
9260
+ return false;
9261
+ }
9262
+ function writeProxyLock(proxyPort, metroPort) {
9263
+ try {
9264
+ fs4.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9265
+ isPrimaryInstance = true;
9266
+ logger8.info(`Wrote proxy lock (port ${proxyPort})`);
9267
+ } catch (err) {
9268
+ logger8.warn("Failed to write proxy lock:", err);
9269
+ }
9270
+ }
9271
+ function cleanProxyLock() {
9272
+ if (!isPrimaryInstance)
9273
+ return;
9274
+ try {
9275
+ const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9276
+ if (lockData.pid === process.pid) {
9277
+ fs4.unlinkSync(PROXY_LOCK_FILE);
9278
+ }
9279
+ } catch {}
9280
+ }
9113
9281
  async function connectToMetro() {
9114
9282
  if (isReconnecting) {
9115
9283
  await waitForReconnect();
@@ -9117,6 +9285,9 @@ async function startServer(config) {
9117
9285
  }
9118
9286
  isReconnecting = true;
9119
9287
  try {
9288
+ if (await tryConnectViaProxy()) {
9289
+ return true;
9290
+ }
9120
9291
  let servers;
9121
9292
  if (config.metro.autoDiscover) {
9122
9293
  servers = await scanMetroPorts(config.metro.host);
@@ -9139,6 +9310,10 @@ async function startServer(config) {
9139
9310
  eventsClient.connect(server.host, server.port);
9140
9311
  activeDeviceKey = `${server.port}-${target.id}`;
9141
9312
  activeDeviceName = target.title || target.deviceName || target.id;
9313
+ const proxyPort = config.proxy;
9314
+ if (proxyPort?.port) {
9315
+ writeProxyLock(proxyPort.port, server.port);
9316
+ }
9142
9317
  return true;
9143
9318
  } catch (err) {
9144
9319
  logger8.warn("Could not connect to Metro:", err);
@@ -9171,7 +9346,15 @@ async function startServer(config) {
9171
9346
  if (config.proxy?.enabled !== false) {
9172
9347
  cdpProxy = new CDPProxy(cdpClient);
9173
9348
  try {
9174
- const proxyPort = await cdpProxy.start(config.proxy?.port ?? 0);
9349
+ let preferredProxyPort = config.proxy?.port ?? 0;
9350
+ if (preferredProxyPort === 0) {
9351
+ try {
9352
+ const stale = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9353
+ if (stale.port)
9354
+ preferredProxyPort = stale.port;
9355
+ } catch {}
9356
+ }
9357
+ const proxyPort = await cdpProxy.start(preferredProxyPort);
9175
9358
  const devtoolsUrl = cdpProxy.getDevToolsUrl();
9176
9359
  logger8.info(`CDP proxy started on port ${proxyPort}`);
9177
9360
  if (devtoolsUrl) {
@@ -9190,13 +9373,18 @@ async function startServer(config) {
9190
9373
  await mcpServer.connect(transport);
9191
9374
  logger8.info("MCP server started");
9192
9375
  process.on("SIGINT", () => {
9376
+ cleanProxyLock();
9193
9377
  cdpProxy?.stop();
9194
9378
  process.exit(0);
9195
9379
  });
9196
9380
  process.on("SIGTERM", () => {
9381
+ cleanProxyLock();
9197
9382
  cdpProxy?.stop();
9198
9383
  process.exit(0);
9199
9384
  });
9385
+ process.on("exit", () => {
9386
+ cleanProxyLock();
9387
+ });
9200
9388
  connectToMetro();
9201
9389
  }
9202
9390
 
package/dist/index.js CHANGED
@@ -3325,6 +3325,7 @@ function mergeConfig(target, source) {
3325
3325
  // src/server.ts
3326
3326
  import { exec } from "child_process";
3327
3327
  import { promisify } from "util";
3328
+ import fs4 from "fs";
3328
3329
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3329
3330
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3330
3331
  import { z as z23 } from "zod";
@@ -3748,7 +3749,7 @@ function extractCDPExceptionMessage(details, fallback = "Evaluation failed") {
3748
3749
  // package.json
3749
3750
  var package_default = {
3750
3751
  name: "metro-mcp",
3751
- version: "0.6.2",
3752
+ version: "0.6.4",
3752
3753
  description: "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
3753
3754
  homepage: "https://metromcp.dev",
3754
3755
  repository: {
@@ -3944,45 +3945,103 @@ class DeviceBufferManager {
3944
3945
  }
3945
3946
 
3946
3947
  // src/plugins/console.ts
3948
+ function formatPreview(preview) {
3949
+ if (!preview.properties)
3950
+ return null;
3951
+ const props = preview.properties.map((p) => {
3952
+ if (p.type === "object" && p.valuePreview) {
3953
+ const nested = formatPreview(p.valuePreview);
3954
+ return `${p.name}: ${nested || p.value || "[object]"}`;
3955
+ }
3956
+ return `${p.name}: ${p.value}`;
3957
+ });
3958
+ const overflow = preview.overflow ? ", ..." : "";
3959
+ if (preview.subtype === "array")
3960
+ return `[${props.join(", ")}${overflow}]`;
3961
+ return `{${props.join(", ")}${overflow}}`;
3962
+ }
3963
+ function formatRemoteObject(obj) {
3964
+ if (obj.type === "string")
3965
+ return obj.value;
3966
+ if (obj.type === "number")
3967
+ return String(obj.value);
3968
+ if (obj.type === "boolean")
3969
+ return String(obj.value);
3970
+ if (obj.type === "undefined")
3971
+ return "undefined";
3972
+ if (obj.subtype === "null")
3973
+ return "null";
3974
+ if (obj.preview) {
3975
+ const formatted = formatPreview(obj.preview);
3976
+ if (formatted)
3977
+ return formatted;
3978
+ }
3979
+ if (obj.description)
3980
+ return obj.description;
3981
+ if (obj.value !== undefined)
3982
+ return JSON.stringify(obj.value);
3983
+ return obj.className || obj.type || "[object]";
3984
+ }
3947
3985
  function formatCDPArgs(args) {
3948
3986
  return args.map((arg) => {
3949
3987
  if (typeof arg === "object" && arg !== null) {
3950
- const remoteObj = arg;
3951
- if (remoteObj.type === "string")
3952
- return remoteObj.value;
3953
- if (remoteObj.type === "number")
3954
- return String(remoteObj.value);
3955
- if (remoteObj.type === "boolean")
3956
- return String(remoteObj.value);
3957
- if (remoteObj.type === "undefined")
3958
- return "undefined";
3959
- if (remoteObj.subtype === "null")
3960
- return "null";
3961
- if (remoteObj.description)
3962
- return remoteObj.description;
3963
- if (remoteObj.value !== undefined)
3964
- return JSON.stringify(remoteObj.value);
3965
- return remoteObj.className || remoteObj.type || "[object]";
3988
+ return formatRemoteObject(arg);
3966
3989
  }
3967
3990
  return String(arg);
3968
3991
  }).join(" ");
3969
3992
  }
3993
+ async function resolveRemoteObject(cdpSend, objectId) {
3994
+ try {
3995
+ const result = await cdpSend("Runtime.callFunctionOn", {
3996
+ objectId,
3997
+ functionDeclaration: 'function() { try { return JSON.stringify(this, null, 2); } catch(e) { return "[unserializable]"; } }',
3998
+ returnByValue: true
3999
+ });
4000
+ const inner = result.result;
4001
+ return inner?.value ? inner.value : null;
4002
+ } catch {
4003
+ return null;
4004
+ }
4005
+ }
4006
+ async function formatCDPArgsDeep(cdpSend, args) {
4007
+ const parts = await Promise.all(args.map(async (arg) => {
4008
+ if (typeof arg !== "object" || arg === null)
4009
+ return String(arg);
4010
+ const remoteObj = arg;
4011
+ if (remoteObj.objectId && remoteObj.type === "object") {
4012
+ const deep = await resolveRemoteObject(cdpSend, remoteObj.objectId);
4013
+ if (deep)
4014
+ return deep;
4015
+ }
4016
+ return formatRemoteObject(remoteObj);
4017
+ }));
4018
+ return parts.join(" ");
4019
+ }
3970
4020
  var consolePlugin = definePlugin({
3971
4021
  name: "console",
3972
4022
  description: "Console log collection and filtering",
3973
4023
  async setup(ctx) {
3974
4024
  const buffers = new DeviceBufferManager(500);
4025
+ const cdpSend = ctx.cdp.send.bind(ctx.cdp);
3975
4026
  ctx.cdp.on("Runtime.consoleAPICalled", (params) => {
3976
4027
  const key = ctx.getActiveDeviceKey();
3977
4028
  if (!key)
3978
4029
  return;
3979
4030
  const args = params.args || [];
3980
- buffers.getOrCreate(key).push({
4031
+ const entry = {
3981
4032
  timestamp: Date.now(),
3982
4033
  level: params.type,
3983
4034
  message: formatCDPArgs(args),
3984
4035
  stackTrace: params.stackTrace ? JSON.stringify(params.stackTrace.callFrames) : undefined
3985
- });
4036
+ };
4037
+ buffers.getOrCreate(key).push(entry);
4038
+ const hasResolvable = args.some((arg) => typeof arg === "object" && arg !== null && arg.objectId);
4039
+ if (hasResolvable) {
4040
+ formatCDPArgsDeep(cdpSend, args).then((deep) => {
4041
+ if (deep !== entry.message)
4042
+ entry.message = deep;
4043
+ }).catch(() => {});
4044
+ }
3986
4045
  });
3987
4046
  ctx.events.on("bundle_transform_progressed", (event) => {
3988
4047
  if (event.transformedFileCount === 1) {
@@ -8594,9 +8653,10 @@ var inspectPointPlugin = definePlugin({
8594
8653
  });
8595
8654
 
8596
8655
  // src/plugins/devtools.ts
8597
- import { spawn as spawn2 } from "child_process";
8656
+ import fs3 from "fs";
8598
8657
  import { z as z22 } from "zod";
8599
8658
  var logger6 = createLogger("devtools");
8659
+ var DEVTOOLS_STATE_FILE = "/tmp/metro-mcp-devtools.json";
8600
8660
  async function findBrowserPath() {
8601
8661
  try {
8602
8662
  const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
@@ -8612,6 +8672,43 @@ async function findBrowserPath() {
8612
8672
  } catch {}
8613
8673
  return null;
8614
8674
  }
8675
+ async function tryFocusExisting(frontendUrl) {
8676
+ try {
8677
+ const state = JSON.parse(fs3.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
8678
+ if (!state.pid || !state.remoteDebuggingPort)
8679
+ return false;
8680
+ try {
8681
+ process.kill(state.pid, 0);
8682
+ } catch {
8683
+ return false;
8684
+ }
8685
+ const resp = await fetch(`http://localhost:${state.remoteDebuggingPort}/json`, {
8686
+ signal: AbortSignal.timeout(1000)
8687
+ });
8688
+ if (!resp.ok)
8689
+ return false;
8690
+ const targets = await resp.json();
8691
+ const target = targets.find((t) => t.url?.includes("rn_fusebox") || t.url === frontendUrl);
8692
+ if (!target?.id)
8693
+ return false;
8694
+ const activate = await fetch(`http://localhost:${state.remoteDebuggingPort}/json/activate/${target.id}`, { signal: AbortSignal.timeout(1000) });
8695
+ return activate.ok;
8696
+ } catch {
8697
+ return false;
8698
+ }
8699
+ }
8700
+ async function launchDevTools(frontendUrl) {
8701
+ const { launch: launch2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
8702
+ const chrome2 = await launch2({
8703
+ chromeFlags: [`--app=${frontendUrl}`, "--window-size=1200,600"]
8704
+ });
8705
+ chrome2.process.unref();
8706
+ try {
8707
+ fs3.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
8708
+ } catch (err) {
8709
+ logger6.warn("Failed to write devtools state:", err);
8710
+ }
8711
+ }
8615
8712
  var devtoolsPlugin = definePlugin({
8616
8713
  name: "devtools",
8617
8714
  description: "Open React Native DevTools via the CDP proxy",
@@ -8633,11 +8730,13 @@ var devtoolsPlugin = definePlugin({
8633
8730
  const browserPath = await findBrowserPath();
8634
8731
  if (browserPath) {
8635
8732
  try {
8636
- const child = spawn2(browserPath, [`--app=${frontendUrl}`, "--window-size=1200,600"], { detached: true, stdio: "ignore" });
8637
- child.unref();
8733
+ const focused = await tryFocusExisting(frontendUrl);
8734
+ if (!focused) {
8735
+ await launchDevTools(frontendUrl);
8736
+ }
8638
8737
  return { opened: true, url: frontendUrl };
8639
8738
  } catch (err) {
8640
- logger6.debug("Failed to launch browser:", err);
8739
+ logger6.debug("Failed to open DevTools:", err);
8641
8740
  }
8642
8741
  } else {
8643
8742
  logger6.debug("No Chrome/Edge installation found");
@@ -8689,20 +8788,34 @@ class CDPProxy {
8689
8788
  return this._port;
8690
8789
  }
8691
8790
  async start(port = 0) {
8692
- return new Promise((resolve, reject) => {
8693
- this.httpServer = http.createServer((req, res) => {
8694
- this.handleHttpRequest(req, res);
8791
+ const tryPort = (p) => new Promise((resolve, reject) => {
8792
+ const httpServer = http.createServer((req, res) => this.handleHttpRequest(req, res));
8793
+ httpServer.on("error", (err) => {
8794
+ httpServer.close();
8795
+ reject(err);
8695
8796
  });
8696
- this.wss = new WebSocketServer({ server: this.httpServer });
8697
- this.wss.on("connection", (ws) => this.handleNewClient(ws));
8698
- this.httpServer.on("error", reject);
8699
- this.httpServer.listen(port, () => {
8700
- const addr = this.httpServer.address();
8701
- this._port = typeof addr === "object" && addr ? addr.port : port;
8702
- logger7.info(`CDP proxy listening on port ${this._port}`);
8703
- resolve(this._port);
8797
+ httpServer.listen(p, () => {
8798
+ const addr = httpServer.address();
8799
+ const actualPort = typeof addr === "object" && addr ? addr.port : p;
8800
+ const wss = new WebSocketServer({ server: httpServer });
8801
+ wss.on("connection", (ws) => this.handleNewClient(ws));
8802
+ this.httpServer = httpServer;
8803
+ this.wss = wss;
8804
+ this._port = actualPort;
8805
+ logger7.info(`CDP proxy listening on port ${actualPort}`);
8806
+ resolve(actualPort);
8704
8807
  });
8705
8808
  });
8809
+ if (port !== 0) {
8810
+ try {
8811
+ return await tryPort(port);
8812
+ } catch (err) {
8813
+ if (err.code !== "EADDRINUSE")
8814
+ throw err;
8815
+ logger7.debug(`Preferred proxy port ${port} in use, falling back to auto-assign`);
8816
+ }
8817
+ }
8818
+ return tryPort(0);
8706
8819
  }
8707
8820
  async stop() {
8708
8821
  for (const client of this.clients.values()) {
@@ -9113,6 +9226,61 @@ async function startServer(config) {
9113
9226
  logger8.error(`Failed to initialize plugin ${plugin.name}:`, err);
9114
9227
  }
9115
9228
  }
9229
+ const PROXY_LOCK_FILE = "/tmp/metro-mcp-proxy.json";
9230
+ let isPrimaryInstance = false;
9231
+ async function tryConnectViaProxy() {
9232
+ try {
9233
+ const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9234
+ if (lockData.pid && lockData.port) {
9235
+ try {
9236
+ process.kill(lockData.pid, 0);
9237
+ } catch {
9238
+ return false;
9239
+ }
9240
+ const resp = await fetch(`http://127.0.0.1:${lockData.port}/json`, {
9241
+ signal: AbortSignal.timeout(2000)
9242
+ });
9243
+ if (!resp.ok)
9244
+ return false;
9245
+ const targets = await resp.json();
9246
+ if (targets.length > 0 && targets[0].webSocketDebuggerUrl) {
9247
+ logger8.info(`Found existing metro-mcp proxy (PID ${lockData.pid}, port ${lockData.port}) — connecting as secondary`);
9248
+ await cdpClient.connect(targets[0]);
9249
+ if (lockData.metroPort) {
9250
+ eventsClient.connect(config.metro.host, lockData.metroPort);
9251
+ config.metro.port = lockData.metroPort;
9252
+ }
9253
+ config.proxy = {
9254
+ ...config.proxy,
9255
+ port: lockData.port
9256
+ };
9257
+ activeDeviceKey = targets[0].id ? `${lockData.port}-${targets[0].id}` : null;
9258
+ activeDeviceName = targets[0].title || targets[0].id || "secondary";
9259
+ return true;
9260
+ }
9261
+ }
9262
+ } catch {}
9263
+ return false;
9264
+ }
9265
+ function writeProxyLock(proxyPort, metroPort) {
9266
+ try {
9267
+ fs4.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9268
+ isPrimaryInstance = true;
9269
+ logger8.info(`Wrote proxy lock (port ${proxyPort})`);
9270
+ } catch (err) {
9271
+ logger8.warn("Failed to write proxy lock:", err);
9272
+ }
9273
+ }
9274
+ function cleanProxyLock() {
9275
+ if (!isPrimaryInstance)
9276
+ return;
9277
+ try {
9278
+ const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9279
+ if (lockData.pid === process.pid) {
9280
+ fs4.unlinkSync(PROXY_LOCK_FILE);
9281
+ }
9282
+ } catch {}
9283
+ }
9116
9284
  async function connectToMetro() {
9117
9285
  if (isReconnecting) {
9118
9286
  await waitForReconnect();
@@ -9120,6 +9288,9 @@ async function startServer(config) {
9120
9288
  }
9121
9289
  isReconnecting = true;
9122
9290
  try {
9291
+ if (await tryConnectViaProxy()) {
9292
+ return true;
9293
+ }
9123
9294
  let servers;
9124
9295
  if (config.metro.autoDiscover) {
9125
9296
  servers = await scanMetroPorts(config.metro.host);
@@ -9142,6 +9313,10 @@ async function startServer(config) {
9142
9313
  eventsClient.connect(server.host, server.port);
9143
9314
  activeDeviceKey = `${server.port}-${target.id}`;
9144
9315
  activeDeviceName = target.title || target.deviceName || target.id;
9316
+ const proxyPort = config.proxy;
9317
+ if (proxyPort?.port) {
9318
+ writeProxyLock(proxyPort.port, server.port);
9319
+ }
9145
9320
  return true;
9146
9321
  } catch (err) {
9147
9322
  logger8.warn("Could not connect to Metro:", err);
@@ -9174,7 +9349,15 @@ async function startServer(config) {
9174
9349
  if (config.proxy?.enabled !== false) {
9175
9350
  cdpProxy = new CDPProxy(cdpClient);
9176
9351
  try {
9177
- const proxyPort = await cdpProxy.start(config.proxy?.port ?? 0);
9352
+ let preferredProxyPort = config.proxy?.port ?? 0;
9353
+ if (preferredProxyPort === 0) {
9354
+ try {
9355
+ const stale = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9356
+ if (stale.port)
9357
+ preferredProxyPort = stale.port;
9358
+ } catch {}
9359
+ }
9360
+ const proxyPort = await cdpProxy.start(preferredProxyPort);
9178
9361
  const devtoolsUrl = cdpProxy.getDevToolsUrl();
9179
9362
  logger8.info(`CDP proxy started on port ${proxyPort}`);
9180
9363
  if (devtoolsUrl) {
@@ -9193,13 +9376,18 @@ async function startServer(config) {
9193
9376
  await mcpServer.connect(transport);
9194
9377
  logger8.info("MCP server started");
9195
9378
  process.on("SIGINT", () => {
9379
+ cleanProxyLock();
9196
9380
  cdpProxy?.stop();
9197
9381
  process.exit(0);
9198
9382
  });
9199
9383
  process.on("SIGTERM", () => {
9384
+ cleanProxyLock();
9200
9385
  cdpProxy?.stop();
9201
9386
  process.exit(0);
9202
9387
  });
9388
+ process.on("exit", () => {
9389
+ cleanProxyLock();
9390
+ });
9203
9391
  connectToMetro();
9204
9392
  }
9205
9393
 
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/metro/proxy.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAyBjD;;;;;;;;;;;GAWG;AACH,qBAAa,QAAQ;IAUP,OAAO,CAAC,QAAQ,CAAC,SAAS;IATtC,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,OAAO,CAAqC;IACpD,OAAO,CAAC,eAAe,CAA0C;IACjE,OAAO,CAAC,eAAe,CAA6B;IACpD,OAAO,CAAC,YAAY,CAAa;IACjC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,KAAK,CAAuB;gBAEP,SAAS,EAAE,SAAS;IAsBjD,IAAI,IAAI,IAAI,MAAM,GAAG,IAAI,CAExB;IAED;;OAEG;IACG,KAAK,CAAC,IAAI,SAAI,GAAG,OAAO,CAAC,MAAM,CAAC;IAmBtC;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA8B3B;;OAEG;IACH,cAAc,IAAI,MAAM,GAAG,IAAI;IAO/B,OAAO,CAAC,iBAAiB;IAiCzB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,mBAAmB;IA2E3B;;;OAGG;IACH,OAAO,CAAC,qBAAqB;IA+B7B,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,aAAa;IA6BrB;;OAEG;IACH,OAAO,CAAC,eAAe;CAgBxB"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/metro/proxy.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAyBjD;;;;;;;;;;;GAWG;AACH,qBAAa,QAAQ;IAUP,OAAO,CAAC,QAAQ,CAAC,SAAS;IATtC,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,OAAO,CAAqC;IACpD,OAAO,CAAC,eAAe,CAA0C;IACjE,OAAO,CAAC,eAAe,CAA6B;IACpD,OAAO,CAAC,YAAY,CAAa;IACjC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,KAAK,CAAuB;gBAEP,SAAS,EAAE,SAAS;IAsBjD,IAAI,IAAI,IAAI,MAAM,GAAG,IAAI,CAExB;IAED;;OAEG;IACG,KAAK,CAAC,IAAI,SAAI,GAAG,OAAO,CAAC,MAAM,CAAC;IAiCtC;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IA8B3B;;OAEG;IACH,cAAc,IAAI,MAAM,GAAG,IAAI;IAO/B,OAAO,CAAC,iBAAiB;IAiCzB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,mBAAmB;IA2E3B;;;OAGG;IACH,OAAO,CAAC,qBAAqB;IA+B7B,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,aAAa;IA6BrB;;OAEG;IACH,OAAO,CAAC,eAAe;CAgBxB"}
@@ -1 +1 @@
1
- {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/plugins/console.ts"],"names":[],"mappings":"AA+BA,eAAO,MAAM,aAAa,yCAkHxB,CAAC"}
1
+ {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/plugins/console.ts"],"names":[],"mappings":"AAuHA,eAAO,MAAM,aAAa,yCAiIxB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"devtools.d.ts","sourceRoot":"","sources":["../../src/plugins/devtools.ts"],"names":[],"mappings":"AA4BA,eAAO,MAAM,cAAc,yCA4DzB,CAAC"}
1
+ {"version":3,"file":"devtools.d.ts","sourceRoot":"","sources":["../../src/plugins/devtools.ts"],"names":[],"mappings":"AAuEA,eAAO,MAAM,cAAc,yCAyDzB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,cAAc,EAOf,MAAM,aAAa,CAAC;AA+DrB,wBAAsB,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAiUjF"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,cAAc,EAOf,MAAM,aAAa,CAAC;AAgErB,wBAAsB,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAyZjF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro-mcp",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
5
5
  "homepage": "https://metromcp.dev",
6
6
  "repository": {