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.
- package/dist/bin/metro-mcp.js +198 -71
- package/dist/index.js +198 -71
- package/dist/plugins/devtools.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/bin/metro-mcp.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
|
|
1013
|
+
import { join } from "path";
|
|
1014
1014
|
import childProcess from "child_process";
|
|
1015
|
-
import { mkdirSync
|
|
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 =
|
|
1080
|
-
|
|
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
|
|
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 ||
|
|
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(
|
|
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
|
|
2483
|
-
var versArr =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
5148
|
-
const parts =
|
|
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 "${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
8685
|
-
import { join as
|
|
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 =
|
|
8689
|
-
var SCRIPT_PATH =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
9051
|
+
async function tryFocusExisting2(frontendUrl) {
|
|
8960
9052
|
try {
|
|
8961
|
-
const state = JSON.parse(
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
9135
|
+
const frontendUrl = buildFrontendUrl(ctx.metro.host, ctx.metro.port, `127.0.0.1:${proxyPort}`);
|
|
9013
9136
|
if (open) {
|
|
9014
|
-
const browserPath = await
|
|
9137
|
+
const browserPath = await findBrowserPath2();
|
|
9015
9138
|
if (browserPath) {
|
|
9016
9139
|
try {
|
|
9017
|
-
const focused = await
|
|
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(
|
|
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
|
-
|
|
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(
|
|
9419
|
+
const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
|
|
9297
9420
|
if (lockData.pid === process.pid) {
|
|
9298
|
-
|
|
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
|
-
|
|
9335
|
-
|
|
9336
|
-
|
|
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
|
-
|
|
9368
|
-
|
|
9512
|
+
let preferredProxyPort = config.proxy?.port ?? 0;
|
|
9513
|
+
if (preferredProxyPort === 0) {
|
|
9369
9514
|
try {
|
|
9370
|
-
|
|
9371
|
-
if (
|
|
9372
|
-
|
|
9373
|
-
|
|
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
|
|
1013
|
+
import { join } from "path";
|
|
1014
1014
|
import childProcess from "child_process";
|
|
1015
|
-
import { mkdirSync
|
|
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 =
|
|
1080
|
-
|
|
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
|
|
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 ||
|
|
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(
|
|
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
|
|
2483
|
-
var versArr =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
5151
|
-
const parts =
|
|
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 "${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
8688
|
-
import { join as
|
|
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 =
|
|
8692
|
-
var SCRIPT_PATH =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
9054
|
+
async function tryFocusExisting2(frontendUrl) {
|
|
8963
9055
|
try {
|
|
8964
|
-
const state = JSON.parse(
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
9138
|
+
const frontendUrl = buildFrontendUrl(ctx.metro.host, ctx.metro.port, `127.0.0.1:${proxyPort}`);
|
|
9016
9139
|
if (open) {
|
|
9017
|
-
const browserPath = await
|
|
9140
|
+
const browserPath = await findBrowserPath2();
|
|
9018
9141
|
if (browserPath) {
|
|
9019
9142
|
try {
|
|
9020
|
-
const focused = await
|
|
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(
|
|
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
|
-
|
|
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(
|
|
9422
|
+
const lockData = JSON.parse(fs5.readFileSync(PROXY_LOCK_FILE, "utf8"));
|
|
9300
9423
|
if (lockData.pid === process.pid) {
|
|
9301
|
-
|
|
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
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
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
|
-
|
|
9371
|
-
|
|
9515
|
+
let preferredProxyPort = config.proxy?.port ?? 0;
|
|
9516
|
+
if (preferredProxyPort === 0) {
|
|
9372
9517
|
try {
|
|
9373
|
-
|
|
9374
|
-
if (
|
|
9375
|
-
|
|
9376
|
-
|
|
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":"
|
|
1
|
+
{"version":3,"file":"devtools.d.ts","sourceRoot":"","sources":["../../src/plugins/devtools.ts"],"names":[],"mappings":"AA4EA,eAAO,MAAM,cAAc,yCA6FzB,CAAC"}
|
package/dist/server.d.ts.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
65
|
+
"metro-bridge": "^0.2.2",
|
|
65
66
|
"ws": "^8.20.0",
|
|
66
67
|
"zod": "^3.24.4"
|
|
67
68
|
},
|