metro-mcp 0.7.2 → 0.8.1

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,8 @@ 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.1",
4121
+ mcpName: "io.github.steve228uk/metro-mcp",
4033
4122
  description: "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
4034
4123
  homepage: "https://metromcp.dev",
4035
4124
  repository: {
@@ -4090,7 +4179,7 @@ var package_default = {
4090
4179
  "@modelcontextprotocol/sdk": "^1.12.1",
4091
4180
  "chrome-launcher": "^1.2.1",
4092
4181
  "chromium-edge-launcher": "^0.3.0",
4093
- "metro-bridge": "^0.1.2",
4182
+ "metro-bridge": "^0.2.2",
4094
4183
  ws: "^8.20.0",
4095
4184
  zod: "^3.24.4"
4096
4185
  },
@@ -5119,7 +5208,7 @@ var reduxPlugin = definePlugin({
5119
5208
  path: z7.string().optional().describe('Dot-separated path to a state slice (e.g., "user.profile")'),
5120
5209
  compact: z7.boolean().default(false).describe("Return compact format")
5121
5210
  }),
5122
- handler: async ({ path, compact: isCompact }) => {
5211
+ handler: async ({ path: path2, compact: isCompact }) => {
5123
5212
  const getStateExpr = `
5124
5213
  (function() {
5125
5214
  // Client SDK
@@ -5144,14 +5233,14 @@ var reduxPlugin = definePlugin({
5144
5233
  return "Redux store not found. Ensure Redux DevTools extension is enabled, or use the metro-mcp client SDK, or expose your store globally.";
5145
5234
  }
5146
5235
  let result = state;
5147
- if (path && typeof state === "object" && state !== null) {
5148
- const parts = path.split(".");
5236
+ if (path2 && typeof state === "object" && state !== null) {
5237
+ const parts = path2.split(".");
5149
5238
  let current = state;
5150
5239
  for (const part of parts) {
5151
5240
  if (current && typeof current === "object") {
5152
5241
  current = current[part];
5153
5242
  } else {
5154
- return `Path "${path}" not found in state.`;
5243
+ return `Path "${path2}" not found in state.`;
5155
5244
  }
5156
5245
  }
5157
5246
  result = current;
@@ -6928,8 +7017,8 @@ var commandsPlugin = definePlugin({
6928
7017
  // src/plugins/test-recorder.ts
6929
7018
  import { z as z16 } from "zod";
6930
7019
  import { readdir, readFile as readFile3, writeFile, mkdir } from "node:fs/promises";
6931
- import { homedir } from "node:os";
6932
- import { join } from "node:path";
7020
+ import { homedir as homedir2 } from "node:os";
7021
+ import { join as join2 } from "node:path";
6933
7022
  var CURRENT_ROUTE_JS = `
6934
7023
  (function() {
6935
7024
  try {
@@ -7124,7 +7213,7 @@ var START_RECORDING_JS = `
7124
7213
  return true;
7125
7214
  })()
7126
7215
  `;
7127
- var RECORDINGS_DIR = join(homedir(), ".metro-mcp", "recordings");
7216
+ var RECORDINGS_DIR = join2(homedir2(), ".metro-mcp", "recordings");
7128
7217
  function appiumSelector(ev) {
7129
7218
  if (ev.testID)
7130
7219
  return `~${ev.testID}`;
@@ -7374,7 +7463,7 @@ var testRecorderPlugin = definePlugin({
7374
7463
  }
7375
7464
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7376
7465
  await mkdir(RECORDINGS_DIR, { recursive: true });
7377
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7466
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7378
7467
  await writeFile(filePath, JSON.stringify({ savedAt: new Date().toISOString(), events }, null, 2), "utf8");
7379
7468
  return { saved: filePath, eventCount: events.length };
7380
7469
  }
@@ -7386,7 +7475,7 @@ var testRecorderPlugin = definePlugin({
7386
7475
  }),
7387
7476
  handler: async ({ filename }) => {
7388
7477
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7389
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7478
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7390
7479
  let raw;
7391
7480
  try {
7392
7481
  raw = await readFile3(filePath, "utf8");
@@ -8120,13 +8209,13 @@ ${NOT_SETUP_MSG}`);
8120
8209
  parameters: z17.object({
8121
8210
  clear: z17.boolean().default(false).describe("Clear the render buffer after reading.")
8122
8211
  }),
8123
- handler: async ({ clear }) => {
8212
+ handler: async ({ clear: clear2 }) => {
8124
8213
  try {
8125
- const raw = await ctx.evalInApp(clear ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8214
+ const raw = await ctx.evalInApp(clear2 ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8126
8215
  if (!raw)
8127
8216
  return NOT_SETUP_MSG;
8128
8217
  if (raw.length === 0)
8129
- return clear ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8218
+ return clear2 ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8130
8219
  return [...raw].sort((a, b) => b.actualDuration - a.actualDuration).map((r) => ({
8131
8220
  id: r.id,
8132
8221
  phase: r.phase,
@@ -8680,13 +8769,13 @@ var automationPlugin = definePlugin({
8680
8769
  });
8681
8770
 
8682
8771
  // 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";
8772
+ import { writeFileSync, mkdirSync as mkdirSync2 } from "node:fs";
8773
+ import { homedir as homedir3 } from "node:os";
8774
+ import { join as join3 } from "node:path";
8686
8775
  import { z as z19 } from "zod";
8687
8776
  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");
8777
+ var CLAUDE_DIR = join3(homedir3(), ".claude");
8778
+ var SCRIPT_PATH = join3(CLAUDE_DIR, "metro-mcp-statusline.sh");
8690
8779
  var SCRIPT_CONTENT = `#!/bin/bash
8691
8780
  # Metro MCP status line for Claude Code
8692
8781
  # Shows CDP connection status of the Metro bundler.
@@ -8741,7 +8830,7 @@ var statuslinePlugin = definePlugin({
8741
8830
  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
8831
  parameters: z19.object({}),
8743
8832
  handler: async () => {
8744
- mkdirSync(CLAUDE_DIR, { recursive: true });
8833
+ mkdirSync2(CLAUDE_DIR, { recursive: true });
8745
8834
  writeFileSync(SCRIPT_PATH, SCRIPT_CONTENT, { mode: 493 });
8746
8835
  return [
8747
8836
  `Metro MCP status script written to ${SCRIPT_PATH}`,
@@ -8937,11 +9026,14 @@ var inspectPointPlugin = definePlugin({
8937
9026
  });
8938
9027
 
8939
9028
  // src/plugins/devtools.ts
8940
- import fs3 from "fs";
9029
+ import fs4 from "fs";
8941
9030
  import { z as z22 } from "zod";
8942
9031
  var logger8 = createLogger("devtools");
8943
9032
  var DEVTOOLS_STATE_FILE = "/tmp/metro-mcp-devtools.json";
8944
- async function findBrowserPath() {
9033
+ function buildFrontendUrl(metroHost, metroPort, wsEndpoint) {
9034
+ return `http://${metroHost}:${metroPort}/debugger-frontend/rn_fusebox.html?ws=${wsEndpoint}&sources.hide_add_folder=true`;
9035
+ }
9036
+ async function findBrowserPath2() {
8945
9037
  try {
8946
9038
  const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
8947
9039
  const path2 = Launcher2.getFirstInstallation();
@@ -8956,9 +9048,9 @@ async function findBrowserPath() {
8956
9048
  } catch {}
8957
9049
  return null;
8958
9050
  }
8959
- async function tryFocusExisting(frontendUrl) {
9051
+ async function tryFocusExisting2(frontendUrl) {
8960
9052
  try {
8961
- const state = JSON.parse(fs3.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
9053
+ const state = JSON.parse(fs4.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
8962
9054
  if (!state.pid || !state.remoteDebuggingPort)
8963
9055
  return false;
8964
9056
  try {
@@ -8988,7 +9080,7 @@ async function launchDevTools(frontendUrl) {
8988
9080
  });
8989
9081
  chrome2.process.unref();
8990
9082
  try {
8991
- fs3.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
9083
+ fs4.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
8992
9084
  } catch (err) {
8993
9085
  logger8.warn("Failed to write devtools state:", err);
8994
9086
  }
@@ -8998,23 +9090,54 @@ var devtoolsPlugin = definePlugin({
8998
9090
  description: "Open React Native DevTools via the CDP proxy",
8999
9091
  async setup(ctx) {
9000
9092
  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.",
9093
+ 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
9094
  parameters: z22.object({
9003
9095
  open: z22.boolean().default(true).describe("Attempt to open the browser automatically")
9004
9096
  }),
9005
9097
  handler: async ({ open }) => {
9098
+ const target = ctx.cdp.getTarget();
9099
+ if (!target) {
9100
+ return "Not connected to Metro. Start your React Native app and try again.";
9101
+ }
9102
+ if (supportsMultipleDebuggers(target)) {
9103
+ let frontendUrl2;
9104
+ if (target.devtoolsFrontendUrl) {
9105
+ frontendUrl2 = `http://${ctx.metro.host}:${ctx.metro.port}${target.devtoolsFrontendUrl}`;
9106
+ } else {
9107
+ let wsHost;
9108
+ try {
9109
+ wsHost = new URL(target.webSocketDebuggerUrl).host;
9110
+ } catch {
9111
+ return "Could not parse Metro WebSocket URL. Try reconnecting.";
9112
+ }
9113
+ frontendUrl2 = buildFrontendUrl(ctx.metro.host, ctx.metro.port, wsHost);
9114
+ }
9115
+ if (open) {
9116
+ try {
9117
+ const result = await openDevTools(frontendUrl2);
9118
+ return { opened: result.opened, url: frontendUrl2 };
9119
+ } catch (err) {
9120
+ logger8.debug("Failed to open DevTools:", err);
9121
+ }
9122
+ }
9123
+ return {
9124
+ opened: false,
9125
+ url: frontendUrl2,
9126
+ instructions: "Open this URL in Chrome or Edge: " + frontendUrl2
9127
+ };
9128
+ }
9006
9129
  const config = ctx.config;
9007
9130
  const proxyConfig = config.proxy;
9008
9131
  const proxyPort = proxyConfig?.port;
9009
9132
  if (!proxyPort) {
9010
9133
  return "CDP proxy is not running. Set proxy.enabled to true in your metro-mcp config.";
9011
9134
  }
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`;
9135
+ const frontendUrl = buildFrontendUrl(ctx.metro.host, ctx.metro.port, `127.0.0.1:${proxyPort}`);
9013
9136
  if (open) {
9014
- const browserPath = await findBrowserPath();
9137
+ const browserPath = await findBrowserPath2();
9015
9138
  if (browserPath) {
9016
9139
  try {
9017
- const focused = await tryFocusExisting(frontendUrl);
9140
+ const focused = await tryFocusExisting2(frontendUrl);
9018
9141
  if (!focused) {
9019
9142
  await launchDevTools(frontendUrl);
9020
9143
  }
@@ -9248,7 +9371,7 @@ async function startServer(config) {
9248
9371
  let isPrimaryInstance = false;
9249
9372
  async function tryConnectViaProxy() {
9250
9373
  try {
9251
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9374
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9252
9375
  if (lockData.pid && lockData.port) {
9253
9376
  try {
9254
9377
  process.kill(lockData.pid, 0);
@@ -9282,7 +9405,7 @@ async function startServer(config) {
9282
9405
  }
9283
9406
  function writeProxyLock(proxyPort, metroPort) {
9284
9407
  try {
9285
- fs4.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9408
+ fs5.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9286
9409
  isPrimaryInstance = true;
9287
9410
  logger9.info(`Wrote proxy lock (port ${proxyPort})`);
9288
9411
  } catch (err) {
@@ -9293,9 +9416,9 @@ async function startServer(config) {
9293
9416
  if (!isPrimaryInstance)
9294
9417
  return;
9295
9418
  try {
9296
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9419
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9297
9420
  if (lockData.pid === process.pid) {
9298
- fs4.unlinkSync(PROXY_LOCK_FILE);
9421
+ fs5.unlinkSync(PROXY_LOCK_FILE);
9299
9422
  }
9300
9423
  } catch {}
9301
9424
  }
@@ -9331,9 +9454,31 @@ async function startServer(config) {
9331
9454
  eventsClient.connect(server.host, server.port);
9332
9455
  activeDeviceKey = `${server.port}-${target.id}`;
9333
9456
  activeDeviceName = target.title || target.deviceName || target.id;
9334
- const proxyPort = config.proxy;
9335
- if (proxyPort?.port) {
9336
- writeProxyLock(proxyPort.port, server.port);
9457
+ if (supportsMultipleDebuggers(target)) {
9458
+ logger9.info("Target supports multiple debuggers (RN 0.85+) — skipping CDP proxy");
9459
+ } else {
9460
+ if (!cdpMultiplexer && config.proxy?.enabled !== false) {
9461
+ const mux = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9462
+ try {
9463
+ const startedPort = await mux.start(preferredProxyPort);
9464
+ const devtoolsUrl = mux.getDevToolsUrl();
9465
+ logger9.info(`CDP proxy started on port ${startedPort}`);
9466
+ if (devtoolsUrl)
9467
+ logger9.info(`Chrome DevTools URL: ${devtoolsUrl}`);
9468
+ config.proxy = {
9469
+ ...config.proxy,
9470
+ port: startedPort,
9471
+ url: devtoolsUrl
9472
+ };
9473
+ cdpMultiplexer = mux;
9474
+ } catch (err) {
9475
+ logger9.warn("Could not start CDP proxy:", err);
9476
+ }
9477
+ }
9478
+ const proxyConfig = config.proxy;
9479
+ if (proxyConfig?.port) {
9480
+ writeProxyLock(proxyConfig.port, server.port);
9481
+ }
9337
9482
  }
9338
9483
  return true;
9339
9484
  } catch (err) {
@@ -9364,31 +9509,13 @@ async function startServer(config) {
9364
9509
  }, delay2);
9365
9510
  }
9366
9511
  let cdpMultiplexer = null;
9367
- if (config.proxy?.enabled !== false) {
9368
- cdpMultiplexer = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9512
+ let preferredProxyPort = config.proxy?.port ?? 0;
9513
+ if (preferredProxyPort === 0) {
9369
9514
  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
- }
9515
+ const stale = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9516
+ if (stale.port)
9517
+ preferredProxyPort = stale.port;
9518
+ } catch {}
9392
9519
  }
9393
9520
  const transport = new StdioServerTransport;
9394
9521
  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,8 @@ 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.1",
4129
+ mcpName: "io.github.steve228uk/metro-mcp",
4041
4130
  description: "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
4042
4131
  homepage: "https://metromcp.dev",
4043
4132
  repository: {
@@ -4098,7 +4187,7 @@ var package_default = {
4098
4187
  "@modelcontextprotocol/sdk": "^1.12.1",
4099
4188
  "chrome-launcher": "^1.2.1",
4100
4189
  "chromium-edge-launcher": "^0.3.0",
4101
- "metro-bridge": "^0.1.2",
4190
+ "metro-bridge": "^0.2.2",
4102
4191
  ws: "^8.20.0",
4103
4192
  zod: "^3.24.4"
4104
4193
  },
@@ -5122,7 +5211,7 @@ var reduxPlugin = definePlugin({
5122
5211
  path: z7.string().optional().describe('Dot-separated path to a state slice (e.g., "user.profile")'),
5123
5212
  compact: z7.boolean().default(false).describe("Return compact format")
5124
5213
  }),
5125
- handler: async ({ path, compact: isCompact }) => {
5214
+ handler: async ({ path: path2, compact: isCompact }) => {
5126
5215
  const getStateExpr = `
5127
5216
  (function() {
5128
5217
  // Client SDK
@@ -5147,14 +5236,14 @@ var reduxPlugin = definePlugin({
5147
5236
  return "Redux store not found. Ensure Redux DevTools extension is enabled, or use the metro-mcp client SDK, or expose your store globally.";
5148
5237
  }
5149
5238
  let result = state;
5150
- if (path && typeof state === "object" && state !== null) {
5151
- const parts = path.split(".");
5239
+ if (path2 && typeof state === "object" && state !== null) {
5240
+ const parts = path2.split(".");
5152
5241
  let current = state;
5153
5242
  for (const part of parts) {
5154
5243
  if (current && typeof current === "object") {
5155
5244
  current = current[part];
5156
5245
  } else {
5157
- return `Path "${path}" not found in state.`;
5246
+ return `Path "${path2}" not found in state.`;
5158
5247
  }
5159
5248
  }
5160
5249
  result = current;
@@ -6931,8 +7020,8 @@ var commandsPlugin = definePlugin({
6931
7020
  // src/plugins/test-recorder.ts
6932
7021
  import { z as z16 } from "zod";
6933
7022
  import { readdir, readFile as readFile3, writeFile, mkdir } from "node:fs/promises";
6934
- import { homedir } from "node:os";
6935
- import { join } from "node:path";
7023
+ import { homedir as homedir2 } from "node:os";
7024
+ import { join as join2 } from "node:path";
6936
7025
  var CURRENT_ROUTE_JS = `
6937
7026
  (function() {
6938
7027
  try {
@@ -7127,7 +7216,7 @@ var START_RECORDING_JS = `
7127
7216
  return true;
7128
7217
  })()
7129
7218
  `;
7130
- var RECORDINGS_DIR = join(homedir(), ".metro-mcp", "recordings");
7219
+ var RECORDINGS_DIR = join2(homedir2(), ".metro-mcp", "recordings");
7131
7220
  function appiumSelector(ev) {
7132
7221
  if (ev.testID)
7133
7222
  return `~${ev.testID}`;
@@ -7377,7 +7466,7 @@ var testRecorderPlugin = definePlugin({
7377
7466
  }
7378
7467
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7379
7468
  await mkdir(RECORDINGS_DIR, { recursive: true });
7380
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7469
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7381
7470
  await writeFile(filePath, JSON.stringify({ savedAt: new Date().toISOString(), events }, null, 2), "utf8");
7382
7471
  return { saved: filePath, eventCount: events.length };
7383
7472
  }
@@ -7389,7 +7478,7 @@ var testRecorderPlugin = definePlugin({
7389
7478
  }),
7390
7479
  handler: async ({ filename }) => {
7391
7480
  const safe = filename.replace(/[^a-zA-Z0-9_-]/g, "_");
7392
- const filePath = join(RECORDINGS_DIR, `${safe}.json`);
7481
+ const filePath = join2(RECORDINGS_DIR, `${safe}.json`);
7393
7482
  let raw;
7394
7483
  try {
7395
7484
  raw = await readFile3(filePath, "utf8");
@@ -8123,13 +8212,13 @@ ${NOT_SETUP_MSG}`);
8123
8212
  parameters: z17.object({
8124
8213
  clear: z17.boolean().default(false).describe("Clear the render buffer after reading.")
8125
8214
  }),
8126
- handler: async ({ clear }) => {
8215
+ handler: async ({ clear: clear2 }) => {
8127
8216
  try {
8128
- const raw = await ctx.evalInApp(clear ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8217
+ const raw = await ctx.evalInApp(clear2 ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
8129
8218
  if (!raw)
8130
8219
  return NOT_SETUP_MSG;
8131
8220
  if (raw.length === 0)
8132
- return clear ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8221
+ return clear2 ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
8133
8222
  return [...raw].sort((a, b) => b.actualDuration - a.actualDuration).map((r) => ({
8134
8223
  id: r.id,
8135
8224
  phase: r.phase,
@@ -8683,13 +8772,13 @@ var automationPlugin = definePlugin({
8683
8772
  });
8684
8773
 
8685
8774
  // 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";
8775
+ import { writeFileSync, mkdirSync as mkdirSync2 } from "node:fs";
8776
+ import { homedir as homedir3 } from "node:os";
8777
+ import { join as join3 } from "node:path";
8689
8778
  import { z as z19 } from "zod";
8690
8779
  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");
8780
+ var CLAUDE_DIR = join3(homedir3(), ".claude");
8781
+ var SCRIPT_PATH = join3(CLAUDE_DIR, "metro-mcp-statusline.sh");
8693
8782
  var SCRIPT_CONTENT = `#!/bin/bash
8694
8783
  # Metro MCP status line for Claude Code
8695
8784
  # Shows CDP connection status of the Metro bundler.
@@ -8744,7 +8833,7 @@ var statuslinePlugin = definePlugin({
8744
8833
  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
8834
  parameters: z19.object({}),
8746
8835
  handler: async () => {
8747
- mkdirSync(CLAUDE_DIR, { recursive: true });
8836
+ mkdirSync2(CLAUDE_DIR, { recursive: true });
8748
8837
  writeFileSync(SCRIPT_PATH, SCRIPT_CONTENT, { mode: 493 });
8749
8838
  return [
8750
8839
  `Metro MCP status script written to ${SCRIPT_PATH}`,
@@ -8940,11 +9029,14 @@ var inspectPointPlugin = definePlugin({
8940
9029
  });
8941
9030
 
8942
9031
  // src/plugins/devtools.ts
8943
- import fs3 from "fs";
9032
+ import fs4 from "fs";
8944
9033
  import { z as z22 } from "zod";
8945
9034
  var logger8 = createLogger("devtools");
8946
9035
  var DEVTOOLS_STATE_FILE = "/tmp/metro-mcp-devtools.json";
8947
- async function findBrowserPath() {
9036
+ function buildFrontendUrl(metroHost, metroPort, wsEndpoint) {
9037
+ return `http://${metroHost}:${metroPort}/debugger-frontend/rn_fusebox.html?ws=${wsEndpoint}&sources.hide_add_folder=true`;
9038
+ }
9039
+ async function findBrowserPath2() {
8948
9040
  try {
8949
9041
  const { Launcher: Launcher2 } = await Promise.resolve().then(() => (init_dist(), exports_dist));
8950
9042
  const path2 = Launcher2.getFirstInstallation();
@@ -8959,9 +9051,9 @@ async function findBrowserPath() {
8959
9051
  } catch {}
8960
9052
  return null;
8961
9053
  }
8962
- async function tryFocusExisting(frontendUrl) {
9054
+ async function tryFocusExisting2(frontendUrl) {
8963
9055
  try {
8964
- const state = JSON.parse(fs3.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
9056
+ const state = JSON.parse(fs4.readFileSync(DEVTOOLS_STATE_FILE, "utf8"));
8965
9057
  if (!state.pid || !state.remoteDebuggingPort)
8966
9058
  return false;
8967
9059
  try {
@@ -8991,7 +9083,7 @@ async function launchDevTools(frontendUrl) {
8991
9083
  });
8992
9084
  chrome2.process.unref();
8993
9085
  try {
8994
- fs3.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
9086
+ fs4.writeFileSync(DEVTOOLS_STATE_FILE, JSON.stringify({ pid: chrome2.pid, remoteDebuggingPort: chrome2.port }));
8995
9087
  } catch (err) {
8996
9088
  logger8.warn("Failed to write devtools state:", err);
8997
9089
  }
@@ -9001,23 +9093,54 @@ var devtoolsPlugin = definePlugin({
9001
9093
  description: "Open React Native DevTools via the CDP proxy",
9002
9094
  async setup(ctx) {
9003
9095
  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.",
9096
+ 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
9097
  parameters: z22.object({
9006
9098
  open: z22.boolean().default(true).describe("Attempt to open the browser automatically")
9007
9099
  }),
9008
9100
  handler: async ({ open }) => {
9101
+ const target = ctx.cdp.getTarget();
9102
+ if (!target) {
9103
+ return "Not connected to Metro. Start your React Native app and try again.";
9104
+ }
9105
+ if (supportsMultipleDebuggers(target)) {
9106
+ let frontendUrl2;
9107
+ if (target.devtoolsFrontendUrl) {
9108
+ frontendUrl2 = `http://${ctx.metro.host}:${ctx.metro.port}${target.devtoolsFrontendUrl}`;
9109
+ } else {
9110
+ let wsHost;
9111
+ try {
9112
+ wsHost = new URL(target.webSocketDebuggerUrl).host;
9113
+ } catch {
9114
+ return "Could not parse Metro WebSocket URL. Try reconnecting.";
9115
+ }
9116
+ frontendUrl2 = buildFrontendUrl(ctx.metro.host, ctx.metro.port, wsHost);
9117
+ }
9118
+ if (open) {
9119
+ try {
9120
+ const result = await openDevTools(frontendUrl2);
9121
+ return { opened: result.opened, url: frontendUrl2 };
9122
+ } catch (err) {
9123
+ logger8.debug("Failed to open DevTools:", err);
9124
+ }
9125
+ }
9126
+ return {
9127
+ opened: false,
9128
+ url: frontendUrl2,
9129
+ instructions: "Open this URL in Chrome or Edge: " + frontendUrl2
9130
+ };
9131
+ }
9009
9132
  const config = ctx.config;
9010
9133
  const proxyConfig = config.proxy;
9011
9134
  const proxyPort = proxyConfig?.port;
9012
9135
  if (!proxyPort) {
9013
9136
  return "CDP proxy is not running. Set proxy.enabled to true in your metro-mcp config.";
9014
9137
  }
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`;
9138
+ const frontendUrl = buildFrontendUrl(ctx.metro.host, ctx.metro.port, `127.0.0.1:${proxyPort}`);
9016
9139
  if (open) {
9017
- const browserPath = await findBrowserPath();
9140
+ const browserPath = await findBrowserPath2();
9018
9141
  if (browserPath) {
9019
9142
  try {
9020
- const focused = await tryFocusExisting(frontendUrl);
9143
+ const focused = await tryFocusExisting2(frontendUrl);
9021
9144
  if (!focused) {
9022
9145
  await launchDevTools(frontendUrl);
9023
9146
  }
@@ -9251,7 +9374,7 @@ async function startServer(config) {
9251
9374
  let isPrimaryInstance = false;
9252
9375
  async function tryConnectViaProxy() {
9253
9376
  try {
9254
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9377
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9255
9378
  if (lockData.pid && lockData.port) {
9256
9379
  try {
9257
9380
  process.kill(lockData.pid, 0);
@@ -9285,7 +9408,7 @@ async function startServer(config) {
9285
9408
  }
9286
9409
  function writeProxyLock(proxyPort, metroPort) {
9287
9410
  try {
9288
- fs4.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9411
+ fs5.writeFileSync(PROXY_LOCK_FILE, JSON.stringify({ pid: process.pid, port: proxyPort, metroPort }));
9289
9412
  isPrimaryInstance = true;
9290
9413
  logger9.info(`Wrote proxy lock (port ${proxyPort})`);
9291
9414
  } catch (err) {
@@ -9296,9 +9419,9 @@ async function startServer(config) {
9296
9419
  if (!isPrimaryInstance)
9297
9420
  return;
9298
9421
  try {
9299
- const lockData = JSON.parse(fs4.readFileSync(PROXY_LOCK_FILE, "utf8"));
9422
+ const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9300
9423
  if (lockData.pid === process.pid) {
9301
- fs4.unlinkSync(PROXY_LOCK_FILE);
9424
+ fs5.unlinkSync(PROXY_LOCK_FILE);
9302
9425
  }
9303
9426
  } catch {}
9304
9427
  }
@@ -9334,9 +9457,31 @@ async function startServer(config) {
9334
9457
  eventsClient.connect(server.host, server.port);
9335
9458
  activeDeviceKey = `${server.port}-${target.id}`;
9336
9459
  activeDeviceName = target.title || target.deviceName || target.id;
9337
- const proxyPort = config.proxy;
9338
- if (proxyPort?.port) {
9339
- writeProxyLock(proxyPort.port, server.port);
9460
+ if (supportsMultipleDebuggers(target)) {
9461
+ logger9.info("Target supports multiple debuggers (RN 0.85+) — skipping CDP proxy");
9462
+ } else {
9463
+ if (!cdpMultiplexer && config.proxy?.enabled !== false) {
9464
+ const mux = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9465
+ try {
9466
+ const startedPort = await mux.start(preferredProxyPort);
9467
+ const devtoolsUrl = mux.getDevToolsUrl();
9468
+ logger9.info(`CDP proxy started on port ${startedPort}`);
9469
+ if (devtoolsUrl)
9470
+ logger9.info(`Chrome DevTools URL: ${devtoolsUrl}`);
9471
+ config.proxy = {
9472
+ ...config.proxy,
9473
+ port: startedPort,
9474
+ url: devtoolsUrl
9475
+ };
9476
+ cdpMultiplexer = mux;
9477
+ } catch (err) {
9478
+ logger9.warn("Could not start CDP proxy:", err);
9479
+ }
9480
+ }
9481
+ const proxyConfig = config.proxy;
9482
+ if (proxyConfig?.port) {
9483
+ writeProxyLock(proxyConfig.port, server.port);
9484
+ }
9340
9485
  }
9341
9486
  return true;
9342
9487
  } catch (err) {
@@ -9367,31 +9512,13 @@ async function startServer(config) {
9367
9512
  }, delay2);
9368
9513
  }
9369
9514
  let cdpMultiplexer = null;
9370
- if (config.proxy?.enabled !== false) {
9371
- cdpMultiplexer = new CDPMultiplexer(cdpSession, { protectedDomains: ["Runtime", "Network"] });
9515
+ let preferredProxyPort = config.proxy?.port ?? 0;
9516
+ if (preferredProxyPort === 0) {
9372
9517
  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
- }
9518
+ const stale = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
9519
+ if (stale.port)
9520
+ preferredProxyPort = stale.port;
9521
+ } catch {}
9395
9522
  }
9396
9523
  const transport = new StdioServerTransport;
9397
9524
  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,7 @@
1
1
  {
2
2
  "name": "metro-mcp",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
+ "mcpName": "io.github.steve228uk/metro-mcp",
4
5
  "description": "Plugin-based MCP server for React Native/Expo runtime debugging, inspection, and automation via Metro/CDP",
5
6
  "homepage": "https://metromcp.dev",
6
7
  "repository": {
@@ -61,7 +62,7 @@
61
62
  "@modelcontextprotocol/sdk": "^1.12.1",
62
63
  "chrome-launcher": "^1.2.1",
63
64
  "chromium-edge-launcher": "^0.3.0",
64
- "metro-bridge": "^0.1.2",
65
+ "metro-bridge": "^0.2.2",
65
66
  "ws": "^8.20.0",
66
67
  "zod": "^3.24.4"
67
68
  },