metro-mcp 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1010,9 +1010,9 @@ var require_is_wsl = __commonJS((exports, module) => {
1010
1010
  });
1011
1011
 
1012
1012
  // node_modules/chrome-launcher/dist/utils.js
1013
- import { join as join3 } from "path";
1013
+ import { join } from "path";
1014
1014
  import childProcess from "child_process";
1015
- import { mkdirSync as mkdirSync2 } from "fs";
1015
+ import { mkdirSync } from "fs";
1016
1016
  function defaults(val, def) {
1017
1017
  return typeof val === "undefined" ? def : val;
1018
1018
  }
@@ -1076,8 +1076,8 @@ function makeUnixTmpDir() {
1076
1076
  function makeWin32TmpDir() {
1077
1077
  const winTmpPath = process.env.TEMP || process.env.TMP || (process.env.SystemRoot || process.env.windir) + "\\temp";
1078
1078
  const randomNumber = Math.floor(Math.random() * 90000000 + 1e7);
1079
- const tmpdir = join3(winTmpPath, "lighthouse." + randomNumber);
1080
- mkdirSync2(tmpdir, { recursive: true });
1079
+ const tmpdir = join(winTmpPath, "lighthouse." + randomNumber);
1080
+ mkdirSync(tmpdir, { recursive: true });
1081
1081
  return tmpdir;
1082
1082
  }
1083
1083
  var import_is_wsl, LauncherError, ChromePathNotSetError, InvalidUserDataDirectoryError, UnsupportedPlatformError, ChromeNotInstalledError;
@@ -1133,7 +1133,7 @@ __export(exports_chrome_finder, {
1133
1133
  });
1134
1134
  import fs from "fs";
1135
1135
  import path from "path";
1136
- import { homedir as homedir3 } from "os";
1136
+ import { homedir } from "os";
1137
1137
  import { execSync, execFileSync } from "child_process";
1138
1138
  function darwinFast() {
1139
1139
  const priorityOptions = [
@@ -1164,7 +1164,7 @@ function darwin() {
1164
1164
  }
1165
1165
  });
1166
1166
  });
1167
- const home = import_escape_string_regexp.default(process.env.HOME || homedir3());
1167
+ const home = import_escape_string_regexp.default(process.env.HOME || homedir());
1168
1168
  const priorities = [
1169
1169
  { regex: new RegExp(`^${home}/Applications/.*Chrome\\.app`), weight: 50 },
1170
1170
  { regex: new RegExp(`^${home}/Applications/.*Chrome Canary\\.app`), weight: 51 },
@@ -1198,7 +1198,7 @@ function linux() {
1198
1198
  installations.push(customChromePath);
1199
1199
  }
1200
1200
  const desktopInstallationFolders = [
1201
- path.join(homedir3(), ".local/share/applications/"),
1201
+ path.join(homedir(), ".local/share/applications/"),
1202
1202
  "/usr/share/applications/"
1203
1203
  ];
1204
1204
  desktopInstallationFolders.forEach((folder) => {
@@ -2479,8 +2479,8 @@ var require_mkdirp_native = __commonJS((exports, module) => {
2479
2479
  // node_modules/mkdirp/lib/use-native.js
2480
2480
  var require_use_native = __commonJS((exports, module) => {
2481
2481
  var fs3 = __require("fs");
2482
- var version2 = process.env.__TESTING_MKDIRP_NODE_VERSION__ || process.version;
2483
- var versArr = version2.replace(/^v/, "").split(".");
2482
+ var version = process.env.__TESTING_MKDIRP_NODE_VERSION__ || process.version;
2483
+ var versArr = version.replace(/^v/, "").split(".");
2484
2484
  var hasNative = +versArr[0] > 10 || +versArr[0] === 10 && +versArr[1] >= 12;
2485
2485
  var useNative = !hasNative ? () => false : (opts) => opts.mkdir === fs3.mkdir;
2486
2486
  var useNativeSync = !hasNative ? () => false : (opts) => opts.mkdirSync === fs3.mkdirSync;
@@ -3317,7 +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
+ import fs5 from "fs";
3321
3321
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3322
3322
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3323
3323
  import { z as z23 } from "zod";
@@ -3326,6 +3326,7 @@ import { z as z23 } from "zod";
3326
3326
  import WebSocket from "ws";
3327
3327
  import http from "http";
3328
3328
  import WebSocket2, { WebSocketServer } from "ws";
3329
+ import fs3 from "fs";
3329
3330
  function createLogger2(name) {
3330
3331
  const prefix = `[metro-bridge:${name}]`;
3331
3332
  return {
@@ -3396,7 +3397,15 @@ class CDPSession {
3396
3397
  }
3397
3398
  return new Promise((resolve, reject) => {
3398
3399
  try {
3399
- this.ws = new WebSocket(url);
3400
+ const wsOrigin = (() => {
3401
+ try {
3402
+ const u = new URL(url.replace(/^wss?:\/\//, (m) => m === "wss://" ? "https://" : "http://"));
3403
+ return `${u.protocol}//${u.host}`;
3404
+ } catch {
3405
+ return "http://localhost:8081";
3406
+ }
3407
+ })();
3408
+ this.ws = new WebSocket(url, { headers: { Origin: wsOrigin } });
3400
3409
  const socketForThisConnection = this.ws;
3401
3410
  this.ws.on("open", () => {
3402
3411
  this._isConnected = true;
@@ -3406,6 +3415,7 @@ class CDPSession {
3406
3415
  resolve();
3407
3416
  });
3408
3417
  this.ws.on("message", (data) => {
3418
+ this.lastPingAt = Date.now();
3409
3419
  this.handleMessage(wsDataToString(data));
3410
3420
  });
3411
3421
  this.ws.on("close", (code, reason) => {
@@ -3598,6 +3608,9 @@ async function scanMetroPorts(host, specificPort) {
3598
3608
  }));
3599
3609
  return results;
3600
3610
  }
3611
+ function supportsMultipleDebuggers(target) {
3612
+ return target.reactNative?.capabilities?.supportsMultipleDebuggers === true;
3613
+ }
3601
3614
  async function checkMetroStatus(host, port) {
3602
3615
  try {
3603
3616
  const response = await fetch(`http://${host}:${port}/status`, {
@@ -3627,6 +3640,10 @@ class CDPMultiplexer {
3627
3640
  constructor(cdpSession, options) {
3628
3641
  this.cdpSession = cdpSession;
3629
3642
  this.protectedDomains = new Set(options?.protectedDomains ?? []);
3643
+ const target = cdpSession.getTarget();
3644
+ if (target?.reactNative?.capabilities?.supportsMultipleDebuggers) {
3645
+ logger3.debug("CDPMultiplexer is not needed: target reports supportsMultipleDebuggers=true. " + "On RN 0.85+ Metro handles multiple concurrent connections natively.");
3646
+ }
3630
3647
  cdpSession.messageInterceptor = (parsed, raw) => this.handleUpstreamMessage(parsed, raw);
3631
3648
  cdpSession.on("reconnected", () => {
3632
3649
  this.reEnableDomains();
@@ -3876,6 +3893,77 @@ class CDPMultiplexer {
3876
3893
  }
3877
3894
  }
3878
3895
  var logger4 = createLogger2("devtools");
3896
+ var DEFAULT_STATE_FILE = "/tmp/metro-bridge-devtools.json";
3897
+ async function findBrowserPath() {
3898
+ try {
3899
+ const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
3900
+ const path2 = Launcher2.getFirstInstallation();
3901
+ if (path2)
3902
+ return path2;
3903
+ } catch {}
3904
+ try {
3905
+ const { Launcher: EdgeLauncher } = await Promise.resolve().then(() => __toESM(require_dist2(), 1));
3906
+ const path2 = EdgeLauncher.getFirstInstallation();
3907
+ if (path2)
3908
+ return path2;
3909
+ } catch {}
3910
+ return null;
3911
+ }
3912
+ async function tryFocusExisting(frontendUrl, stateFile) {
3913
+ try {
3914
+ const state = JSON.parse(fs3.readFileSync(stateFile, "utf8"));
3915
+ if (!state.pid || !state.remoteDebuggingPort)
3916
+ return false;
3917
+ try {
3918
+ process.kill(state.pid, 0);
3919
+ } catch {
3920
+ return false;
3921
+ }
3922
+ const resp = await fetch(`http://localhost:${state.remoteDebuggingPort}/json`, {
3923
+ signal: AbortSignal.timeout(1000)
3924
+ });
3925
+ if (!resp.ok)
3926
+ return false;
3927
+ const targets = await resp.json();
3928
+ const target = targets.find((t) => t.url?.includes("rn_fusebox") || t.url === frontendUrl);
3929
+ if (!target?.id)
3930
+ return false;
3931
+ const activate = await fetch(`http://localhost:${state.remoteDebuggingPort}/json/activate/${target.id}`, { signal: AbortSignal.timeout(1000) });
3932
+ return activate.ok;
3933
+ } catch {
3934
+ return false;
3935
+ }
3936
+ }
3937
+ async function launchBrowser(frontendUrl, stateFile) {
3938
+ const { launch: launch2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
3939
+ const chrome2 = await launch2({
3940
+ chromeFlags: [`--app=${frontendUrl}`, "--window-size=1200,600"]
3941
+ });
3942
+ chrome2.process.unref();
3943
+ try {
3944
+ fs3.writeFileSync(stateFile, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
3945
+ } catch (err) {
3946
+ logger4.warn("Failed to write devtools state:", err);
3947
+ }
3948
+ }
3949
+ async function openDevTools(frontendUrl, options) {
3950
+ const stateFile = options?.stateFile ?? DEFAULT_STATE_FILE;
3951
+ const browserPath = await findBrowserPath();
3952
+ if (browserPath) {
3953
+ try {
3954
+ const focused = await tryFocusExisting(frontendUrl, stateFile);
3955
+ if (!focused) {
3956
+ await launchBrowser(frontendUrl, stateFile);
3957
+ }
3958
+ return { opened: true, url: frontendUrl };
3959
+ } catch (err) {
3960
+ logger4.debug("Failed to open DevTools:", err);
3961
+ }
3962
+ } else {
3963
+ logger4.debug("No Chrome/Edge installation found");
3964
+ }
3965
+ return { opened: false, url: frontendUrl };
3966
+ }
3879
3967
 
3880
3968
  // src/metro/events.ts
3881
3969
  import WebSocket3 from "ws";
@@ -4029,7 +4117,7 @@ function extractCDPExceptionMessage(details, fallback = "Evaluation failed") {
4029
4117
  // package.json
4030
4118
  var package_default = {
4031
4119
  name: "metro-mcp",
4032
- version: "0.7.2",
4120
+ version: "0.8.0",
4033
4121
  description: "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
4034
4122
  homepage: "https://metromcp.dev",
4035
4123
  repository: {
@@ -4090,7 +4178,7 @@ var package_default = {
4090
4178
  "@modelcontextprotocol/sdk": "^1.12.1",
4091
4179
  "chrome-launcher": "^1.2.1",
4092
4180
  "chromium-edge-launcher": "^0.3.0",
4093
- "metro-bridge": "^0.1.2",
4181
+ "metro-bridge": "^0.2.2",
4094
4182
  ws: "^8.20.0",
4095
4183
  zod: "^3.24.4"
4096
4184
  },
@@ -5119,7 +5207,7 @@ var reduxPlugin = definePlugin({
5119
5207
  path: z7.string().optional().describe('Dot-separated path to a state slice (e.g., "user.profile")'),
5120
5208
  compact: z7.boolean().default(false).describe("Return compact format")
5121
5209
  }),
5122
- handler: async ({ path, compact: isCompact }) => {
5210
+ handler: async ({ path: path2, compact: isCompact }) => {
5123
5211
  const getStateExpr = `
5124
5212
  (function() {
5125
5213
  // Client SDK
@@ -5144,14 +5232,14 @@ var reduxPlugin = definePlugin({
5144
5232
  return "Redux store not found. Ensure Redux DevTools extension is enabled, or use the metro-mcp client SDK, or expose your store globally.";
5145
5233
  }
5146
5234
  let result = state;
5147
- if (path && typeof state === "object" && state !== null) {
5148
- const parts = path.split(".");
5235
+ if (path2 && typeof state === "object" && state !== null) {
5236
+ const parts = path2.split(".");
5149
5237
  let current = state;
5150
5238
  for (const part of parts) {
5151
5239
  if (current && typeof current === "object") {
5152
5240
  current = current[part];
5153
5241
  } else {
5154
- return `Path "${path}" not found in state.`;
5242
+ return `Path "${path2}" not found in state.`;
5155
5243
  }
5156
5244
  }
5157
5245
  result = current;
@@ -6928,8 +7016,8 @@ var commandsPlugin = definePlugin({
6928
7016
  // src/plugins/test-recorder.ts
6929
7017
  import { z as z16 } from "zod";
6930
7018
  import { readdir, readFile as readFile3, writeFile, mkdir } from "node:fs/promises";
6931
- import { homedir } from "node:os";
6932
- import { join } from "node:path";
7019
+ import { homedir as homedir2 } from "node:os";
7020
+ import { join as join2 } from "node:path";
6933
7021
  var CURRENT_ROUTE_JS = `
6934
7022
  (function() {
6935
7023
  try {
@@ -7124,7 +7212,7 @@ var START_RECORDING_JS = `
7124
7212
  return true;
7125
7213
  })()
7126
7214
  `;
7127
- var RECORDINGS_DIR = join(homedir(), ".metro-mcp", "recordings");
7215
+ var RECORDINGS_DIR = join2(homedir2(), ".metro-mcp", "recordings");
7128
7216
  function appiumSelector(ev) {
7129
7217
  if (ev.testID)
7130
7218
  return `~${ev.testID}`;
@@ -7374,7 +7462,7 @@ var testRecorderPlugin = definePlugin({
7374
7462
  }
7375
7463
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7376
7464
  await mkdir(RECORDINGS_DIR, { recursive: true });
7377
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7465
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7378
7466
  await writeFile(filePath, JSON.stringify({ savedAt: new Date().toISOString(), events }, null, 2), "utf8");
7379
7467
  return { saved: filePath, eventCount: events.length };
7380
7468
  }
@@ -7386,7 +7474,7 @@ var testRecorderPlugin = definePlugin({
7386
7474
  }),
7387
7475
  handler: async ({ filename }) => {
7388
7476
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7389
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7477
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7390
7478
  let raw;
7391
7479
  try {
7392
7480
  raw = await readFile3(filePath, "utf8");
@@ -8120,13 +8208,13 @@ ${NOT_SETUP_MSG}`);
8120
8208
  parameters: z17.object({
8121
8209
  clear: z17.boolean().default(false).describe("Clear the render buffer after reading.")
8122
8210
  }),
8123
- handler: async ({ clear }) => {
8211
+ handler: async ({ clear: clear2 }) => {
8124
8212
  try {
8125
- const raw = await ctx.evalInApp(clear ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8213
+ const raw = await ctx.evalInApp(clear2 ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8126
8214
  if (!raw)
8127
8215
  return NOT_SETUP_MSG;
8128
8216
  if (raw.length === 0)
8129
- return clear ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8217
+ return clear2 ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8130
8218
  return [...raw].sort((a, b) => b.actualDuration - a.actualDuration).map((r) => ({
8131
8219
  id: r.id,
8132
8220
  phase: r.phase,
@@ -8680,13 +8768,13 @@ var automationPlugin = definePlugin({
8680
8768
  });
8681
8769
 
8682
8770
  // src/plugins/statusline.ts
8683
- import { writeFileSync, mkdirSync } from "node:fs";
8684
- import { homedir as homedir2 } from "node:os";
8685
- import { join as join2 } from "node:path";
8771
+ import { writeFileSync, mkdirSync as mkdirSync2 } from "node:fs";
8772
+ import { homedir as homedir3 } from "node:os";
8773
+ import { join as join3 } from "node:path";
8686
8774
  import { z as z19 } from "zod";
8687
8775
  var STATUS_FILE = "/tmp/metro-mcp-status.json";
8688
- var CLAUDE_DIR = join2(homedir2(), ".claude");
8689
- var SCRIPT_PATH = join2(CLAUDE_DIR, "metro-mcp-statusline.sh");
8776
+ var CLAUDE_DIR = join3(homedir3(), ".claude");
8777
+ var SCRIPT_PATH = join3(CLAUDE_DIR, "metro-mcp-statusline.sh");
8690
8778
  var SCRIPT_CONTENT = `#!/bin/bash
8691
8779
  # Metro MCP status line for Claude Code
8692
8780
  # Shows CDP connection status of the Metro bundler.
@@ -8741,7 +8829,7 @@ var statuslinePlugin = definePlugin({
8741
8829
  description: "Writes the Metro CDP connection status script to ~/.claude/metro-mcp-statusline.sh. " + "Does not modify settings.json — tell the user to add it to their status line themselves " + '(e.g. ask Claude: "/statusline add the script at ~/.claude/metro-mcp-statusline.sh"). ' + "Only works with Claude Code.",
8742
8830
  parameters: z19.object({}),
8743
8831
  handler: async () => {
8744
- mkdirSync(CLAUDE_DIR, { recursive: true });
8832
+ mkdirSync2(CLAUDE_DIR, { recursive: true });
8745
8833
  writeFileSync(SCRIPT_PATH, SCRIPT_CONTENT, { mode: 493 });
8746
8834
  return [
8747
8835
  `Metro MCP status script written to ${SCRIPT_PATH}`,
@@ -8937,11 +9025,14 @@ var inspectPointPlugin = definePlugin({
8937
9025
  });
8938
9026
 
8939
9027
  // src/plugins/devtools.ts
8940
- import fs3 from "fs";
9028
+ import fs4 from "fs";
8941
9029
  import { z as z22 } from "zod";
8942
9030
  var logger8 = createLogger("devtools");
8943
9031
  var DEVTOOLS_STATE_FILE = "/tmp/metro-mcp-devtools.json";
8944
- async function findBrowserPath() {
9032
+ function buildFrontendUrl(metroHost, metroPort, wsEndpoint) {
9033
+ return `http://${metroHost}:${metroPort}/debugger-frontend/rn_fusebox.html?ws=${wsEndpoint}&sources.hide_add_folder=true`;
9034
+ }
9035
+ async function findBrowserPath2() {
8945
9036
  try {
8946
9037
  const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
8947
9038
  const path2 = Launcher2.getFirstInstallation();
@@ -8956,9 +9047,9 @@ async function findBrowserPath() {
8956
9047
  } catch {}
8957
9048
  return null;
8958
9049
  }
8959
- async function tryFocusExisting(frontendUrl) {
9050
+ async function tryFocusExisting2(frontendUrl) {
8960
9051
  try {
8961
- const state = JSON.parse(fs3.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
9052
+ const state = JSON.parse(fs4.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
8962
9053
  if (!state.pid || !state.remoteDebuggingPort)
8963
9054
  return false;
8964
9055
  try {
@@ -8988,7 +9079,7 @@ async function launchDevTools(frontendUrl) {
8988
9079
  });
8989
9080
  chrome2.process.unref();
8990
9081
  try {
8991
- fs3.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
9082
+ fs4.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
8992
9083
  } catch (err) {
8993
9084
  logger8.warn("Failed to write devtools state:", err);
8994
9085
  }
@@ -8998,23 +9089,54 @@ var devtoolsPlugin = definePlugin({
8998
9089
  description: "Open React Native DevTools via the CDP proxy",
8999
9090
  async setup(ctx) {
9000
9091
  ctx.registerTool("open_devtools", {
9001
- description: "Open the React Native DevTools debugger panel in Chrome. Uses Metro's bundled DevTools frontend but connects through our CDP proxy so both DevTools and the MCP can share the single Hermes connection.",
9092
+ description: "Open the React Native DevTools debugger panel in Chrome. On RN 0.85+ connects directly to Metro (no proxy needed). On older RN versions connects through the CDP proxy so both DevTools and the MCP can share the single Hermes connection.",
9002
9093
  parameters: z22.object({
9003
9094
  open: z22.boolean().default(true).describe("Attempt to open the browser automatically")
9004
9095
  }),
9005
9096
  handler: async ({ open }) => {
9097
+ const target = ctx.cdp.getTarget();
9098
+ if (!target) {
9099
+ return "Not connected to Metro. Start your React Native app and try again.";
9100
+ }
9101
+ if (supportsMultipleDebuggers(target)) {
9102
+ let frontendUrl2;
9103
+ if (target.devtoolsFrontendUrl) {
9104
+ frontendUrl2 = `http://${ctx.metro.host}:${ctx.metro.port}${target.devtoolsFrontendUrl}`;
9105
+ } else {
9106
+ let wsHost;
9107
+ try {
9108
+ wsHost = new URL(target.webSocketDebuggerUrl).host;
9109
+ } catch {
9110
+ return "Could not parse Metro WebSocket URL. Try reconnecting.";
9111
+ }
9112
+ frontendUrl2 = buildFrontendUrl(ctx.metro.host, ctx.metro.port, wsHost);
9113
+ }
9114
+ if (open) {
9115
+ try {
9116
+ const result = await openDevTools(frontendUrl2);
9117
+ return { opened: result.opened, url: frontendUrl2 };
9118
+ } catch (err) {
9119
+ logger8.debug("Failed to open DevTools:", err);
9120
+ }
9121
+ }
9122
+ return {
9123
+ opened: false,
9124
+ url: frontendUrl2,
9125
+ instructions: "Open this URL in Chrome or Edge: " + frontendUrl2
9126
+ };
9127
+ }
9006
9128
  const config = ctx.config;
9007
9129
  const proxyConfig = config.proxy;
9008
9130
  const proxyPort = proxyConfig?.port;
9009
9131
  if (!proxyPort) {
9010
9132
  return "CDP proxy is not running. Set proxy.enabled to true in your metro-mcp config.";
9011
9133
  }
9012
- const frontendUrl = `http://${ctx.metro.host}:${ctx.metro.port}/debugger-frontend/rn_fusebox.html?ws=127.0.0.1:${proxyPort}&sources.hide_add_folder=true`;
9134
+ const frontendUrl = buildFrontendUrl(ctx.metro.host, ctx.metro.port, `127.0.0.1:${proxyPort}`);
9013
9135
  if (open) {
9014
- const browserPath = await findBrowserPath();
9136
+ const browserPath = await findBrowserPath2();
9015
9137
  if (browserPath) {
9016
9138
  try {
9017
- const focused = await tryFocusExisting(frontendUrl);
9139
+ const focused = await tryFocusExisting2(frontendUrl);
9018
9140
  if (!focused) {
9019
9141
  await launchDevTools(frontendUrl);
9020
9142
  }
@@ -9248,7 +9370,7 @@ async function startServer(config) {
9248
9370
  let isPrimaryInstance = false;
9249
9371
  async function tryConnectViaProxy() {
9250
9372
  try {
9251
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9373
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9252
9374
  if (lockData.pid && lockData.port) {
9253
9375
  try {
9254
9376
  process.kill(lockData.pid, 0);
@@ -9282,7 +9404,7 @@ async function startServer(config) {
9282
9404
  }
9283
9405
  function writeProxyLock(proxyPort, metroPort) {
9284
9406
  try {
9285
- fs4.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9407
+ fs5.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9286
9408
  isPrimaryInstance = true;
9287
9409
  logger9.info(`Wrote proxy lock (port ${proxyPort})`);
9288
9410
  } catch (err) {
@@ -9293,9 +9415,9 @@ async function startServer(config) {
9293
9415
  if (!isPrimaryInstance)
9294
9416
  return;
9295
9417
  try {
9296
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9418
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9297
9419
  if (lockData.pid === process.pid) {
9298
- fs4.unlinkSync(PROXY_LOCK_FILE);
9420
+ fs5.unlinkSync(PROXY_LOCK_FILE);
9299
9421
  }
9300
9422
  } catch {}
9301
9423
  }
@@ -9331,9 +9453,31 @@ async function startServer(config) {
9331
9453
  eventsClient.connect(server.host, server.port);
9332
9454
  activeDeviceKey = `${server.port}-${target.id}`;
9333
9455
  activeDeviceName = target.title || target.deviceName || target.id;
9334
- const proxyPort = config.proxy;
9335
- if (proxyPort?.port) {
9336
- writeProxyLock(proxyPort.port, server.port);
9456
+ if (supportsMultipleDebuggers(target)) {
9457
+ logger9.info("Target supports multiple debuggers (RN 0.85+) — skipping CDP proxy");
9458
+ } else {
9459
+ if (!cdpMultiplexer && config.proxy?.enabled !== false) {
9460
+ const mux = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9461
+ try {
9462
+ const startedPort = await mux.start(preferredProxyPort);
9463
+ const devtoolsUrl = mux.getDevToolsUrl();
9464
+ logger9.info(`CDP proxy started on port ${startedPort}`);
9465
+ if (devtoolsUrl)
9466
+ logger9.info(`Chrome DevTools URL: ${devtoolsUrl}`);
9467
+ config.proxy = {
9468
+ ...config.proxy,
9469
+ port: startedPort,
9470
+ url: devtoolsUrl
9471
+ };
9472
+ cdpMultiplexer = mux;
9473
+ } catch (err) {
9474
+ logger9.warn("Could not start CDP proxy:", err);
9475
+ }
9476
+ }
9477
+ const proxyConfig = config.proxy;
9478
+ if (proxyConfig?.port) {
9479
+ writeProxyLock(proxyConfig.port, server.port);
9480
+ }
9337
9481
  }
9338
9482
  return true;
9339
9483
  } catch (err) {
@@ -9364,31 +9508,13 @@ async function startServer(config) {
9364
9508
  }, delay2);
9365
9509
  }
9366
9510
  let cdpMultiplexer = null;
9367
- if (config.proxy?.enabled !== false) {
9368
- cdpMultiplexer = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9511
+ let preferredProxyPort = config.proxy?.port ?? 0;
9512
+ if (preferredProxyPort === 0) {
9369
9513
  try {
9370
- let preferredProxyPort = config.proxy?.port ?? 0;
9371
- if (preferredProxyPort === 0) {
9372
- try {
9373
- const stale = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9374
- if (stale.port)
9375
- preferredProxyPort = stale.port;
9376
- } catch {}
9377
- }
9378
- const proxyPort = await cdpMultiplexer.start(preferredProxyPort);
9379
- const devtoolsUrl = cdpMultiplexer.getDevToolsUrl();
9380
- logger9.info(`CDP proxy started on port ${proxyPort}`);
9381
- if (devtoolsUrl) {
9382
- logger9.info(`Chrome DevTools URL: ${devtoolsUrl}`);
9383
- }
9384
- config.proxy = {
9385
- ...config.proxy,
9386
- port: proxyPort,
9387
- url: devtoolsUrl
9388
- };
9389
- } catch (err) {
9390
- logger9.warn("Could not start CDP proxy:", err);
9391
- }
9514
+ const stale = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9515
+ if (stale.port)
9516
+ preferredProxyPort = stale.port;
9517
+ } catch {}
9392
9518
  }
9393
9519
  const transport = new StdioServerTransport;
9394
9520
  await mcpServer.connect(transport);
package/dist/index.js CHANGED
@@ -1010,9 +1010,9 @@ var require_is_wsl = __commonJS((exports, module) => {
1010
1010
  });
1011
1011
 
1012
1012
  // node_modules/chrome-launcher/dist/utils.js
1013
- import { join as join3 } from "path";
1013
+ import { join } from "path";
1014
1014
  import childProcess from "child_process";
1015
- import { mkdirSync as mkdirSync2 } from "fs";
1015
+ import { mkdirSync } from "fs";
1016
1016
  function defaults(val, def) {
1017
1017
  return typeof val === "undefined" ? def : val;
1018
1018
  }
@@ -1076,8 +1076,8 @@ function makeUnixTmpDir() {
1076
1076
  function makeWin32TmpDir() {
1077
1077
  const winTmpPath = process.env.TEMP || process.env.TMP || (process.env.SystemRoot || process.env.windir) + "\\temp";
1078
1078
  const randomNumber = Math.floor(Math.random() * 90000000 + 1e7);
1079
- const tmpdir = join3(winTmpPath, "lighthouse." + randomNumber);
1080
- mkdirSync2(tmpdir, { recursive: true });
1079
+ const tmpdir = join(winTmpPath, "lighthouse." + randomNumber);
1080
+ mkdirSync(tmpdir, { recursive: true });
1081
1081
  return tmpdir;
1082
1082
  }
1083
1083
  var import_is_wsl, LauncherError, ChromePathNotSetError, InvalidUserDataDirectoryError, UnsupportedPlatformError, ChromeNotInstalledError;
@@ -1133,7 +1133,7 @@ __export(exports_chrome_finder, {
1133
1133
  });
1134
1134
  import fs from "fs";
1135
1135
  import path from "path";
1136
- import { homedir as homedir3 } from "os";
1136
+ import { homedir } from "os";
1137
1137
  import { execSync, execFileSync } from "child_process";
1138
1138
  function darwinFast() {
1139
1139
  const priorityOptions = [
@@ -1164,7 +1164,7 @@ function darwin() {
1164
1164
  }
1165
1165
  });
1166
1166
  });
1167
- const home = import_escape_string_regexp.default(process.env.HOME || homedir3());
1167
+ const home = import_escape_string_regexp.default(process.env.HOME || homedir());
1168
1168
  const priorities = [
1169
1169
  { regex: new RegExp(`^${home}/Applications/.*Chrome\\.app`), weight: 50 },
1170
1170
  { regex: new RegExp(`^${home}/Applications/.*Chrome Canary\\.app`), weight: 51 },
@@ -1198,7 +1198,7 @@ function linux() {
1198
1198
  installations.push(customChromePath);
1199
1199
  }
1200
1200
  const desktopInstallationFolders = [
1201
- path.join(homedir3(), ".local/share/applications/"),
1201
+ path.join(homedir(), ".local/share/applications/"),
1202
1202
  "/usr/share/applications/"
1203
1203
  ];
1204
1204
  desktopInstallationFolders.forEach((folder) => {
@@ -2479,8 +2479,8 @@ var require_mkdirp_native = __commonJS((exports, module) => {
2479
2479
  // node_modules/mkdirp/lib/use-native.js
2480
2480
  var require_use_native = __commonJS((exports, module) => {
2481
2481
  var fs3 = __require("fs");
2482
- var version2 = process.env.__TESTING_MKDIRP_NODE_VERSION__ || process.version;
2483
- var versArr = version2.replace(/^v/, "").split(".");
2482
+ var version = process.env.__TESTING_MKDIRP_NODE_VERSION__ || process.version;
2483
+ var versArr = version.replace(/^v/, "").split(".");
2484
2484
  var hasNative = +versArr[0] > 10 || +versArr[0] === 10 && +versArr[1] >= 12;
2485
2485
  var useNative = !hasNative ? () => false : (opts) => opts.mkdir === fs3.mkdir;
2486
2486
  var useNativeSync = !hasNative ? () => false : (opts) => opts.mkdirSync === fs3.mkdirSync;
@@ -3325,7 +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
+ import fs5 from "fs";
3329
3329
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3330
3330
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3331
3331
  import { z as z23 } from "zod";
@@ -3334,6 +3334,7 @@ import { z as z23 } from "zod";
3334
3334
  import WebSocket from "ws";
3335
3335
  import http from "http";
3336
3336
  import WebSocket2, { WebSocketServer } from "ws";
3337
+ import fs3 from "fs";
3337
3338
  function createLogger2(name) {
3338
3339
  const prefix = `[metro-bridge:${name}]`;
3339
3340
  return {
@@ -3404,7 +3405,15 @@ class CDPSession {
3404
3405
  }
3405
3406
  return new Promise((resolve, reject) => {
3406
3407
  try {
3407
- this.ws = new WebSocket(url);
3408
+ const wsOrigin = (() => {
3409
+ try {
3410
+ const u = new URL(url.replace(/^wss?:\/\//, (m) => m === "wss://" ? "https://" : "http://"));
3411
+ return `${u.protocol}//${u.host}`;
3412
+ } catch {
3413
+ return "http://localhost:8081";
3414
+ }
3415
+ })();
3416
+ this.ws = new WebSocket(url, { headers: { Origin: wsOrigin } });
3408
3417
  const socketForThisConnection = this.ws;
3409
3418
  this.ws.on("open", () => {
3410
3419
  this._isConnected = true;
@@ -3414,6 +3423,7 @@ class CDPSession {
3414
3423
  resolve();
3415
3424
  });
3416
3425
  this.ws.on("message", (data) => {
3426
+ this.lastPingAt = Date.now();
3417
3427
  this.handleMessage(wsDataToString(data));
3418
3428
  });
3419
3429
  this.ws.on("close", (code, reason) => {
@@ -3606,6 +3616,9 @@ async function scanMetroPorts(host, specificPort) {
3606
3616
  }));
3607
3617
  return results;
3608
3618
  }
3619
+ function supportsMultipleDebuggers(target) {
3620
+ return target.reactNative?.capabilities?.supportsMultipleDebuggers === true;
3621
+ }
3609
3622
  async function checkMetroStatus(host, port) {
3610
3623
  try {
3611
3624
  const response = await fetch(`http://${host}:${port}/status`, {
@@ -3635,6 +3648,10 @@ class CDPMultiplexer {
3635
3648
  constructor(cdpSession, options) {
3636
3649
  this.cdpSession = cdpSession;
3637
3650
  this.protectedDomains = new Set(options?.protectedDomains ?? []);
3651
+ const target = cdpSession.getTarget();
3652
+ if (target?.reactNative?.capabilities?.supportsMultipleDebuggers) {
3653
+ logger3.debug("CDPMultiplexer is not needed: target reports supportsMultipleDebuggers=true. " + "On RN 0.85+ Metro handles multiple concurrent connections natively.");
3654
+ }
3638
3655
  cdpSession.messageInterceptor = (parsed, raw) => this.handleUpstreamMessage(parsed, raw);
3639
3656
  cdpSession.on("reconnected", () => {
3640
3657
  this.reEnableDomains();
@@ -3884,6 +3901,77 @@ class CDPMultiplexer {
3884
3901
  }
3885
3902
  }
3886
3903
  var logger4 = createLogger2("devtools");
3904
+ var DEFAULT_STATE_FILE = "/tmp/metro-bridge-devtools.json";
3905
+ async function findBrowserPath() {
3906
+ try {
3907
+ const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
3908
+ const path2 = Launcher2.getFirstInstallation();
3909
+ if (path2)
3910
+ return path2;
3911
+ } catch {}
3912
+ try {
3913
+ const { Launcher: EdgeLauncher } = await Promise.resolve().then(() => __toESM(require_dist2(), 1));
3914
+ const path2 = EdgeLauncher.getFirstInstallation();
3915
+ if (path2)
3916
+ return path2;
3917
+ } catch {}
3918
+ return null;
3919
+ }
3920
+ async function tryFocusExisting(frontendUrl, stateFile) {
3921
+ try {
3922
+ const state = JSON.parse(fs3.readFileSync(stateFile, "utf8"));
3923
+ if (!state.pid || !state.remoteDebuggingPort)
3924
+ return false;
3925
+ try {
3926
+ process.kill(state.pid, 0);
3927
+ } catch {
3928
+ return false;
3929
+ }
3930
+ const resp = await fetch(`http://localhost:${state.remoteDebuggingPort}/json`, {
3931
+ signal: AbortSignal.timeout(1000)
3932
+ });
3933
+ if (!resp.ok)
3934
+ return false;
3935
+ const targets = await resp.json();
3936
+ const target = targets.find((t) => t.url?.includes("rn_fusebox") || t.url === frontendUrl);
3937
+ if (!target?.id)
3938
+ return false;
3939
+ const activate = await fetch(`http://localhost:${state.remoteDebuggingPort}/json/activate/${target.id}`, { signal: AbortSignal.timeout(1000) });
3940
+ return activate.ok;
3941
+ } catch {
3942
+ return false;
3943
+ }
3944
+ }
3945
+ async function launchBrowser(frontendUrl, stateFile) {
3946
+ const { launch: launch2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
3947
+ const chrome2 = await launch2({
3948
+ chromeFlags: [`--app=${frontendUrl}`, "--window-size=1200,600"]
3949
+ });
3950
+ chrome2.process.unref();
3951
+ try {
3952
+ fs3.writeFileSync(stateFile, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
3953
+ } catch (err) {
3954
+ logger4.warn("Failed to write devtools state:", err);
3955
+ }
3956
+ }
3957
+ async function openDevTools(frontendUrl, options) {
3958
+ const stateFile = options?.stateFile ?? DEFAULT_STATE_FILE;
3959
+ const browserPath = await findBrowserPath();
3960
+ if (browserPath) {
3961
+ try {
3962
+ const focused = await tryFocusExisting(frontendUrl, stateFile);
3963
+ if (!focused) {
3964
+ await launchBrowser(frontendUrl, stateFile);
3965
+ }
3966
+ return { opened: true, url: frontendUrl };
3967
+ } catch (err) {
3968
+ logger4.debug("Failed to open DevTools:", err);
3969
+ }
3970
+ } else {
3971
+ logger4.debug("No Chrome/Edge installation found");
3972
+ }
3973
+ return { opened: false, url: frontendUrl };
3974
+ }
3887
3975
 
3888
3976
  // src/metro/events.ts
3889
3977
  import WebSocket3 from "ws";
@@ -4037,7 +4125,7 @@ function extractCDPExceptionMessage(details, fallback = "Evaluation failed") {
4037
4125
  // package.json
4038
4126
  var package_default = {
4039
4127
  name: "metro-mcp",
4040
- version: "0.7.2",
4128
+ version: "0.8.0",
4041
4129
  description: "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
4042
4130
  homepage: "https://metromcp.dev",
4043
4131
  repository: {
@@ -4098,7 +4186,7 @@ var package_default = {
4098
4186
  "@modelcontextprotocol/sdk": "^1.12.1",
4099
4187
  "chrome-launcher": "^1.2.1",
4100
4188
  "chromium-edge-launcher": "^0.3.0",
4101
- "metro-bridge": "^0.1.2",
4189
+ "metro-bridge": "^0.2.2",
4102
4190
  ws: "^8.20.0",
4103
4191
  zod: "^3.24.4"
4104
4192
  },
@@ -5122,7 +5210,7 @@ var reduxPlugin = definePlugin({
5122
5210
  path: z7.string().optional().describe('Dot-separated path to a state slice (e.g., "user.profile")'),
5123
5211
  compact: z7.boolean().default(false).describe("Return compact format")
5124
5212
  }),
5125
- handler: async ({ path, compact: isCompact }) => {
5213
+ handler: async ({ path: path2, compact: isCompact }) => {
5126
5214
  const getStateExpr = `
5127
5215
  (function() {
5128
5216
  // Client SDK
@@ -5147,14 +5235,14 @@ var reduxPlugin = definePlugin({
5147
5235
  return "Redux store not found. Ensure Redux DevTools extension is enabled, or use the metro-mcp client SDK, or expose your store globally.";
5148
5236
  }
5149
5237
  let result = state;
5150
- if (path && typeof state === "object" && state !== null) {
5151
- const parts = path.split(".");
5238
+ if (path2 && typeof state === "object" && state !== null) {
5239
+ const parts = path2.split(".");
5152
5240
  let current = state;
5153
5241
  for (const part of parts) {
5154
5242
  if (current && typeof current === "object") {
5155
5243
  current = current[part];
5156
5244
  } else {
5157
- return `Path "${path}" not found in state.`;
5245
+ return `Path "${path2}" not found in state.`;
5158
5246
  }
5159
5247
  }
5160
5248
  result = current;
@@ -6931,8 +7019,8 @@ var commandsPlugin = definePlugin({
6931
7019
  // src/plugins/test-recorder.ts
6932
7020
  import { z as z16 } from "zod";
6933
7021
  import { readdir, readFile as readFile3, writeFile, mkdir } from "node:fs/promises";
6934
- import { homedir } from "node:os";
6935
- import { join } from "node:path";
7022
+ import { homedir as homedir2 } from "node:os";
7023
+ import { join as join2 } from "node:path";
6936
7024
  var CURRENT_ROUTE_JS = `
6937
7025
  (function() {
6938
7026
  try {
@@ -7127,7 +7215,7 @@ var START_RECORDING_JS = `
7127
7215
  return true;
7128
7216
  })()
7129
7217
  `;
7130
- var RECORDINGS_DIR = join(homedir(), ".metro-mcp", "recordings");
7218
+ var RECORDINGS_DIR = join2(homedir2(), ".metro-mcp", "recordings");
7131
7219
  function appiumSelector(ev) {
7132
7220
  if (ev.testID)
7133
7221
  return `~${ev.testID}`;
@@ -7377,7 +7465,7 @@ var testRecorderPlugin = definePlugin({
7377
7465
  }
7378
7466
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7379
7467
  await mkdir(RECORDINGS_DIR, { recursive: true });
7380
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7468
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7381
7469
  await writeFile(filePath, JSON.stringify({ savedAt: new Date().toISOString(), events }, null, 2), "utf8");
7382
7470
  return { saved: filePath, eventCount: events.length };
7383
7471
  }
@@ -7389,7 +7477,7 @@ var testRecorderPlugin = definePlugin({
7389
7477
  }),
7390
7478
  handler: async ({ filename }) => {
7391
7479
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7392
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7480
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7393
7481
  let raw;
7394
7482
  try {
7395
7483
  raw = await readFile3(filePath, "utf8");
@@ -8123,13 +8211,13 @@ ${NOT_SETUP_MSG}`);
8123
8211
  parameters: z17.object({
8124
8212
  clear: z17.boolean().default(false).describe("Clear the render buffer after reading.")
8125
8213
  }),
8126
- handler: async ({ clear }) => {
8214
+ handler: async ({ clear: clear2 }) => {
8127
8215
  try {
8128
- const raw = await ctx.evalInApp(clear ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8216
+ const raw = await ctx.evalInApp(clear2 ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8129
8217
  if (!raw)
8130
8218
  return NOT_SETUP_MSG;
8131
8219
  if (raw.length === 0)
8132
- return clear ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8220
+ return clear2 ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8133
8221
  return [...raw].sort((a, b) => b.actualDuration - a.actualDuration).map((r) => ({
8134
8222
  id: r.id,
8135
8223
  phase: r.phase,
@@ -8683,13 +8771,13 @@ var automationPlugin = definePlugin({
8683
8771
  });
8684
8772
 
8685
8773
  // src/plugins/statusline.ts
8686
- import { writeFileSync, mkdirSync } from "node:fs";
8687
- import { homedir as homedir2 } from "node:os";
8688
- import { join as join2 } from "node:path";
8774
+ import { writeFileSync, mkdirSync as mkdirSync2 } from "node:fs";
8775
+ import { homedir as homedir3 } from "node:os";
8776
+ import { join as join3 } from "node:path";
8689
8777
  import { z as z19 } from "zod";
8690
8778
  var STATUS_FILE = "/tmp/metro-mcp-status.json";
8691
- var CLAUDE_DIR = join2(homedir2(), ".claude");
8692
- var SCRIPT_PATH = join2(CLAUDE_DIR, "metro-mcp-statusline.sh");
8779
+ var CLAUDE_DIR = join3(homedir3(), ".claude");
8780
+ var SCRIPT_PATH = join3(CLAUDE_DIR, "metro-mcp-statusline.sh");
8693
8781
  var SCRIPT_CONTENT = `#!/bin/bash
8694
8782
  # Metro MCP status line for Claude Code
8695
8783
  # Shows CDP connection status of the Metro bundler.
@@ -8744,7 +8832,7 @@ var statuslinePlugin = definePlugin({
8744
8832
  description: "Writes the Metro CDP connection status script to ~/.claude/metro-mcp-statusline.sh. " + "Does not modify settings.json — tell the user to add it to their status line themselves " + '(e.g. ask Claude: "/statusline add the script at ~/.claude/metro-mcp-statusline.sh"). ' + "Only works with Claude Code.",
8745
8833
  parameters: z19.object({}),
8746
8834
  handler: async () => {
8747
- mkdirSync(CLAUDE_DIR, { recursive: true });
8835
+ mkdirSync2(CLAUDE_DIR, { recursive: true });
8748
8836
  writeFileSync(SCRIPT_PATH, SCRIPT_CONTENT, { mode: 493 });
8749
8837
  return [
8750
8838
  `Metro MCP status script written to ${SCRIPT_PATH}`,
@@ -8940,11 +9028,14 @@ var inspectPointPlugin = definePlugin({
8940
9028
  });
8941
9029
 
8942
9030
  // src/plugins/devtools.ts
8943
- import fs3 from "fs";
9031
+ import fs4 from "fs";
8944
9032
  import { z as z22 } from "zod";
8945
9033
  var logger8 = createLogger("devtools");
8946
9034
  var DEVTOOLS_STATE_FILE = "/tmp/metro-mcp-devtools.json";
8947
- async function findBrowserPath() {
9035
+ function buildFrontendUrl(metroHost, metroPort, wsEndpoint) {
9036
+ return `http://${metroHost}:${metroPort}/debugger-frontend/rn_fusebox.html?ws=${wsEndpoint}&sources.hide_add_folder=true`;
9037
+ }
9038
+ async function findBrowserPath2() {
8948
9039
  try {
8949
9040
  const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
8950
9041
  const path2 = Launcher2.getFirstInstallation();
@@ -8959,9 +9050,9 @@ async function findBrowserPath() {
8959
9050
  } catch {}
8960
9051
  return null;
8961
9052
  }
8962
- async function tryFocusExisting(frontendUrl) {
9053
+ async function tryFocusExisting2(frontendUrl) {
8963
9054
  try {
8964
- const state = JSON.parse(fs3.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
9055
+ const state = JSON.parse(fs4.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
8965
9056
  if (!state.pid || !state.remoteDebuggingPort)
8966
9057
  return false;
8967
9058
  try {
@@ -8991,7 +9082,7 @@ async function launchDevTools(frontendUrl) {
8991
9082
  });
8992
9083
  chrome2.process.unref();
8993
9084
  try {
8994
- fs3.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
9085
+ fs4.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
8995
9086
  } catch (err) {
8996
9087
  logger8.warn("Failed to write devtools state:", err);
8997
9088
  }
@@ -9001,23 +9092,54 @@ var devtoolsPlugin = definePlugin({
9001
9092
  description: "Open React Native DevTools via the CDP proxy",
9002
9093
  async setup(ctx) {
9003
9094
  ctx.registerTool("open_devtools", {
9004
- description: "Open the React Native DevTools debugger panel in Chrome. Uses Metro's bundled DevTools frontend but connects through our CDP proxy so both DevTools and the MCP can share the single Hermes connection.",
9095
+ description: "Open the React Native DevTools debugger panel in Chrome. On RN 0.85+ connects directly to Metro (no proxy needed). On older RN versions connects through the CDP proxy so both DevTools and the MCP can share the single Hermes connection.",
9005
9096
  parameters: z22.object({
9006
9097
  open: z22.boolean().default(true).describe("Attempt to open the browser automatically")
9007
9098
  }),
9008
9099
  handler: async ({ open }) => {
9100
+ const target = ctx.cdp.getTarget();
9101
+ if (!target) {
9102
+ return "Not connected to Metro. Start your React Native app and try again.";
9103
+ }
9104
+ if (supportsMultipleDebuggers(target)) {
9105
+ let frontendUrl2;
9106
+ if (target.devtoolsFrontendUrl) {
9107
+ frontendUrl2 = `http://${ctx.metro.host}:${ctx.metro.port}${target.devtoolsFrontendUrl}`;
9108
+ } else {
9109
+ let wsHost;
9110
+ try {
9111
+ wsHost = new URL(target.webSocketDebuggerUrl).host;
9112
+ } catch {
9113
+ return "Could not parse Metro WebSocket URL. Try reconnecting.";
9114
+ }
9115
+ frontendUrl2 = buildFrontendUrl(ctx.metro.host, ctx.metro.port, wsHost);
9116
+ }
9117
+ if (open) {
9118
+ try {
9119
+ const result = await openDevTools(frontendUrl2);
9120
+ return { opened: result.opened, url: frontendUrl2 };
9121
+ } catch (err) {
9122
+ logger8.debug("Failed to open DevTools:", err);
9123
+ }
9124
+ }
9125
+ return {
9126
+ opened: false,
9127
+ url: frontendUrl2,
9128
+ instructions: "Open this URL in Chrome or Edge: " + frontendUrl2
9129
+ };
9130
+ }
9009
9131
  const config = ctx.config;
9010
9132
  const proxyConfig = config.proxy;
9011
9133
  const proxyPort = proxyConfig?.port;
9012
9134
  if (!proxyPort) {
9013
9135
  return "CDP proxy is not running. Set proxy.enabled to true in your metro-mcp config.";
9014
9136
  }
9015
- const frontendUrl = `http://${ctx.metro.host}:${ctx.metro.port}/debugger-frontend/rn_fusebox.html?ws=127.0.0.1:${proxyPort}&sources.hide_add_folder=true`;
9137
+ const frontendUrl = buildFrontendUrl(ctx.metro.host, ctx.metro.port, `127.0.0.1:${proxyPort}`);
9016
9138
  if (open) {
9017
- const browserPath = await findBrowserPath();
9139
+ const browserPath = await findBrowserPath2();
9018
9140
  if (browserPath) {
9019
9141
  try {
9020
- const focused = await tryFocusExisting(frontendUrl);
9142
+ const focused = await tryFocusExisting2(frontendUrl);
9021
9143
  if (!focused) {
9022
9144
  await launchDevTools(frontendUrl);
9023
9145
  }
@@ -9251,7 +9373,7 @@ async function startServer(config) {
9251
9373
  let isPrimaryInstance = false;
9252
9374
  async function tryConnectViaProxy() {
9253
9375
  try {
9254
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9376
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9255
9377
  if (lockData.pid && lockData.port) {
9256
9378
  try {
9257
9379
  process.kill(lockData.pid, 0);
@@ -9285,7 +9407,7 @@ async function startServer(config) {
9285
9407
  }
9286
9408
  function writeProxyLock(proxyPort, metroPort) {
9287
9409
  try {
9288
- fs4.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9410
+ fs5.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9289
9411
  isPrimaryInstance = true;
9290
9412
  logger9.info(`Wrote proxy lock (port ${proxyPort})`);
9291
9413
  } catch (err) {
@@ -9296,9 +9418,9 @@ async function startServer(config) {
9296
9418
  if (!isPrimaryInstance)
9297
9419
  return;
9298
9420
  try {
9299
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9421
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9300
9422
  if (lockData.pid === process.pid) {
9301
- fs4.unlinkSync(PROXY_LOCK_FILE);
9423
+ fs5.unlinkSync(PROXY_LOCK_FILE);
9302
9424
  }
9303
9425
  } catch {}
9304
9426
  }
@@ -9334,9 +9456,31 @@ async function startServer(config) {
9334
9456
  eventsClient.connect(server.host, server.port);
9335
9457
  activeDeviceKey = `${server.port}-${target.id}`;
9336
9458
  activeDeviceName = target.title || target.deviceName || target.id;
9337
- const proxyPort = config.proxy;
9338
- if (proxyPort?.port) {
9339
- writeProxyLock(proxyPort.port, server.port);
9459
+ if (supportsMultipleDebuggers(target)) {
9460
+ logger9.info("Target supports multiple debuggers (RN 0.85+) — skipping CDP proxy");
9461
+ } else {
9462
+ if (!cdpMultiplexer && config.proxy?.enabled !== false) {
9463
+ const mux = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9464
+ try {
9465
+ const startedPort = await mux.start(preferredProxyPort);
9466
+ const devtoolsUrl = mux.getDevToolsUrl();
9467
+ logger9.info(`CDP proxy started on port ${startedPort}`);
9468
+ if (devtoolsUrl)
9469
+ logger9.info(`Chrome DevTools URL: ${devtoolsUrl}`);
9470
+ config.proxy = {
9471
+ ...config.proxy,
9472
+ port: startedPort,
9473
+ url: devtoolsUrl
9474
+ };
9475
+ cdpMultiplexer = mux;
9476
+ } catch (err) {
9477
+ logger9.warn("Could not start CDP proxy:", err);
9478
+ }
9479
+ }
9480
+ const proxyConfig = config.proxy;
9481
+ if (proxyConfig?.port) {
9482
+ writeProxyLock(proxyConfig.port, server.port);
9483
+ }
9340
9484
  }
9341
9485
  return true;
9342
9486
  } catch (err) {
@@ -9367,31 +9511,13 @@ async function startServer(config) {
9367
9511
  }, delay2);
9368
9512
  }
9369
9513
  let cdpMultiplexer = null;
9370
- if (config.proxy?.enabled !== false) {
9371
- cdpMultiplexer = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9514
+ let preferredProxyPort = config.proxy?.port ?? 0;
9515
+ if (preferredProxyPort === 0) {
9372
9516
  try {
9373
- let preferredProxyPort = config.proxy?.port ?? 0;
9374
- if (preferredProxyPort === 0) {
9375
- try {
9376
- const stale = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9377
- if (stale.port)
9378
- preferredProxyPort = stale.port;
9379
- } catch {}
9380
- }
9381
- const proxyPort = await cdpMultiplexer.start(preferredProxyPort);
9382
- const devtoolsUrl = cdpMultiplexer.getDevToolsUrl();
9383
- logger9.info(`CDP proxy started on port ${proxyPort}`);
9384
- if (devtoolsUrl) {
9385
- logger9.info(`Chrome DevTools URL: ${devtoolsUrl}`);
9386
- }
9387
- config.proxy = {
9388
- ...config.proxy,
9389
- port: proxyPort,
9390
- url: devtoolsUrl
9391
- };
9392
- } catch (err) {
9393
- logger9.warn("Could not start CDP proxy:", err);
9394
- }
9517
+ const stale = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9518
+ if (stale.port)
9519
+ preferredProxyPort = stale.port;
9520
+ } catch {}
9395
9521
  }
9396
9522
  const transport = new StdioServerTransport;
9397
9523
  await mcpServer.connect(transport);
@@ -1 +1 @@
1
- {"version":3,"file":"devtools.d.ts","sourceRoot":"","sources":["../../src/plugins/devtools.ts"],"names":[],"mappings":"AAuEA,eAAO,MAAM,cAAc,yCAyDzB,CAAC"}
1
+ {"version":3,"file":"devtools.d.ts","sourceRoot":"","sources":["../../src/plugins/devtools.ts"],"names":[],"mappings":"AA4EA,eAAO,MAAM,cAAc,yCA6FzB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,cAAc,EAOf,MAAM,aAAa,CAAC;AA8DrB,wBAAsB,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAyZjF"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,cAAc,EAOf,MAAM,aAAa,CAAC;AA8DrB,wBAAsB,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CA8ZjF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro-mcp",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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": {
@@ -61,7 +61,7 @@
61
61
  "@modelcontextprotocol/sdk": "^1.12.1",
62
62
  "chrome-launcher": "^1.2.1",
63
63
  "chromium-edge-launcher": "^0.3.0",
64
- "metro-bridge": "^0.1.2",
64
+ "metro-bridge": "^0.2.2",
65
65
  "ws": "^8.20.0",
66
66
  "zod": "^3.24.4"
67
67
  },