metro-mcp 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/bin/metro-mcp.js +548 -10
- package/dist/client/index.cjs +26 -2
- package/dist/client/index.d.ts +5 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +33 -4
- package/dist/client/performance.d.ts +19 -1
- package/dist/client/performance.d.ts.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/index.js +548 -12
- package/dist/metro/connection.d.ts +0 -3
- package/dist/metro/connection.d.ts.map +1 -1
- package/dist/metro/types.d.ts +6 -0
- package/dist/metro/types.d.ts.map +1 -1
- package/dist/plugin.d.ts +18 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +0 -3
- package/dist/plugins/profiler.d.ts +2 -0
- package/dist/plugins/profiler.d.ts.map +1 -0
- package/dist/plugins/prompts.d.ts.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -87,10 +87,11 @@ metro-mcp connects to your running Metro dev server the same way Chrome DevTools
|
|
|
87
87
|
| **navigation** | 4 | React Navigation / Expo Router state |
|
|
88
88
|
| **accessibility** | 3 | Accessibility auditing |
|
|
89
89
|
| **commands** | 2 | Custom app commands |
|
|
90
|
+
| **profiler** | 5 | CPU profiling (Hermes CDP) + React render tracking |
|
|
90
91
|
| **maestro** | 2 | Maestro test flow generation |
|
|
91
92
|
| **appium** | 3 | Appium/WebdriverIO Jest test generation |
|
|
92
93
|
|
|
93
|
-
**Total:
|
|
94
|
+
**Total: 55 tools, 9 resources, 7 prompts** — see the [full tools reference](docs/tools.md).
|
|
94
95
|
|
|
95
96
|
## App Integration (Optional)
|
|
96
97
|
|
|
@@ -98,7 +99,7 @@ Register custom commands and expose state to the MCP server — no package neede
|
|
|
98
99
|
|
|
99
100
|
```typescript
|
|
100
101
|
if (__DEV__) {
|
|
101
|
-
|
|
102
|
+
globalThis.__METRO_MCP__ = {
|
|
102
103
|
commands: {
|
|
103
104
|
// Run custom actions from the MCP client
|
|
104
105
|
login: async ({ email, password }) => {
|
|
@@ -121,7 +122,7 @@ if (__DEV__) {
|
|
|
121
122
|
|
|
122
123
|
Use `list_commands` and `run_command` to call these from the MCP client.
|
|
123
124
|
|
|
124
|
-
For enhanced features like real-time Redux action tracking, navigation events,
|
|
125
|
+
For enhanced features like real-time Redux action tracking, navigation events, performance marks, and React render profiling, see the [optional client SDK](docs/sdk.md) and [profiling guide](docs/profiling.md).
|
|
125
126
|
|
|
126
127
|
## Configuration
|
|
127
128
|
|
package/dist/bin/metro-mcp.js
CHANGED
|
@@ -32,6 +32,9 @@ var DEFAULT_CONFIG = {
|
|
|
32
32
|
},
|
|
33
33
|
network: {
|
|
34
34
|
interceptFetch: false
|
|
35
|
+
},
|
|
36
|
+
profiler: {
|
|
37
|
+
newArchitecture: true
|
|
35
38
|
}
|
|
36
39
|
};
|
|
37
40
|
async function loadConfig(args) {
|
|
@@ -101,12 +104,16 @@ function mergeConfig(target, source) {
|
|
|
101
104
|
if (source.network.interceptFetch !== undefined)
|
|
102
105
|
target.network.interceptFetch = source.network.interceptFetch;
|
|
103
106
|
}
|
|
107
|
+
if (source.profiler) {
|
|
108
|
+
if (source.profiler.newArchitecture !== undefined)
|
|
109
|
+
target.profiler.newArchitecture = source.profiler.newArchitecture;
|
|
110
|
+
}
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
// src/server.ts
|
|
107
114
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
108
115
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
109
|
-
import { z as
|
|
116
|
+
import { z as z20 } from "zod";
|
|
110
117
|
|
|
111
118
|
// src/metro/connection.ts
|
|
112
119
|
var logger2 = createLogger("cdp");
|
|
@@ -3349,6 +3356,526 @@ var appiumPlugin = definePlugin({
|
|
|
3349
3356
|
}
|
|
3350
3357
|
});
|
|
3351
3358
|
|
|
3359
|
+
// src/plugins/profiler.ts
|
|
3360
|
+
import { z as z19 } from "zod";
|
|
3361
|
+
var SKIP_FN_NAMES = new Set(["(root)", "(idle)", "(program)"]);
|
|
3362
|
+
function analyzeCpuProfile(profile, topN, includeNative) {
|
|
3363
|
+
const durationMs = (profile.endTime - profile.startTime) / 1000;
|
|
3364
|
+
const samples = profile.samples ?? [];
|
|
3365
|
+
const parentMap = new Map;
|
|
3366
|
+
for (const node of profile.nodes) {
|
|
3367
|
+
for (const childId of node.children ?? [])
|
|
3368
|
+
parentMap.set(childId, node.id);
|
|
3369
|
+
}
|
|
3370
|
+
const selfSamplesMap = new Map;
|
|
3371
|
+
const totalSamplesMap = new Map;
|
|
3372
|
+
for (const nodeId of samples) {
|
|
3373
|
+
selfSamplesMap.set(nodeId, (selfSamplesMap.get(nodeId) ?? 0) + 1);
|
|
3374
|
+
let current = nodeId;
|
|
3375
|
+
const visited = new Set;
|
|
3376
|
+
while (current !== undefined && !visited.has(current)) {
|
|
3377
|
+
visited.add(current);
|
|
3378
|
+
totalSamplesMap.set(current, (totalSamplesMap.get(current) ?? 0) + 1);
|
|
3379
|
+
current = parentMap.get(current);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
const total = samples.length || 1;
|
|
3383
|
+
const stats = [];
|
|
3384
|
+
for (const node of profile.nodes) {
|
|
3385
|
+
const self = selfSamplesMap.get(node.id) ?? 0;
|
|
3386
|
+
const tot = totalSamplesMap.get(node.id) ?? 0;
|
|
3387
|
+
if (self === 0 && tot === 0)
|
|
3388
|
+
continue;
|
|
3389
|
+
const fnName = node.callFrame.functionName || "(anonymous)";
|
|
3390
|
+
if (SKIP_FN_NAMES.has(fnName))
|
|
3391
|
+
continue;
|
|
3392
|
+
if (!includeNative && (!node.callFrame.url || node.callFrame.url.startsWith("native ")))
|
|
3393
|
+
continue;
|
|
3394
|
+
stats.push({
|
|
3395
|
+
functionName: fnName,
|
|
3396
|
+
url: node.callFrame.url ?? "",
|
|
3397
|
+
lineNumber: node.callFrame.lineNumber + 1,
|
|
3398
|
+
selfMs: parseFloat((self / total * durationMs).toFixed(2)),
|
|
3399
|
+
selfPercent: parseFloat((self / total * 100).toFixed(1)),
|
|
3400
|
+
totalMs: parseFloat((tot / total * durationMs).toFixed(2)),
|
|
3401
|
+
totalPercent: parseFloat((tot / total * 100).toFixed(1))
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
stats.sort((a, b) => b.selfMs - a.selfMs);
|
|
3405
|
+
return { durationMs: parseFloat(durationMs.toFixed(2)), sampleCount: samples.length, topFunctions: stats.slice(0, topN), totalSamplesMap, selfSamplesMap };
|
|
3406
|
+
}
|
|
3407
|
+
var BAR_WIDTH = 24;
|
|
3408
|
+
var MAX_DEPTH = 8;
|
|
3409
|
+
var MIN_PERCENT = 0.5;
|
|
3410
|
+
function bar(value, max) {
|
|
3411
|
+
const filled = max > 0 ? Math.max(1, Math.round(value / max * BAR_WIDTH)) : 0;
|
|
3412
|
+
return "\u2588".repeat(filled).padEnd(BAR_WIDTH);
|
|
3413
|
+
}
|
|
3414
|
+
function barPct(pct) {
|
|
3415
|
+
return "\u2588".repeat(Math.max(1, Math.round(pct / 100 * BAR_WIDTH))).padEnd(BAR_WIDTH);
|
|
3416
|
+
}
|
|
3417
|
+
function trunc(s, max) {
|
|
3418
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
3419
|
+
}
|
|
3420
|
+
function memoSavings(r) {
|
|
3421
|
+
return r.baseDuration > 0 ? parseFloat(((r.baseDuration - r.actualDuration) / r.baseDuration * 100).toFixed(1)) : null;
|
|
3422
|
+
}
|
|
3423
|
+
function buildCpuFlamegraph(profile, analysis) {
|
|
3424
|
+
const { durationMs, sampleCount, totalSamplesMap, selfSamplesMap, topFunctions } = analysis;
|
|
3425
|
+
const nodeMap = new Map(profile.nodes.map((n) => [n.id, n]));
|
|
3426
|
+
const lines = [];
|
|
3427
|
+
lines.push("=== CPU Flamegraph (by total time) ===");
|
|
3428
|
+
lines.push(`Duration: ${durationMs}ms | Samples: ${sampleCount}`);
|
|
3429
|
+
lines.push("");
|
|
3430
|
+
function renderNode(nodeId, depth) {
|
|
3431
|
+
if (depth > MAX_DEPTH)
|
|
3432
|
+
return;
|
|
3433
|
+
const node = nodeMap.get(nodeId);
|
|
3434
|
+
if (!node)
|
|
3435
|
+
return;
|
|
3436
|
+
const fnName = node.callFrame.functionName || "(anonymous)";
|
|
3437
|
+
if (SKIP_FN_NAMES.has(fnName) && depth === 0) {
|
|
3438
|
+
for (const c of node.children ?? [])
|
|
3439
|
+
renderNode(c, depth);
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
const total = totalSamplesMap.get(nodeId) ?? 0;
|
|
3443
|
+
const self = selfSamplesMap.get(nodeId) ?? 0;
|
|
3444
|
+
const totalPct = sampleCount > 0 ? total / sampleCount * 100 : 0;
|
|
3445
|
+
const selfPct = sampleCount > 0 ? self / sampleCount * 100 : 0;
|
|
3446
|
+
if (totalPct < MIN_PERCENT && depth > 0)
|
|
3447
|
+
return;
|
|
3448
|
+
const indent = " ".repeat(depth);
|
|
3449
|
+
const hasChildren = (node.children ?? []).some((c) => sampleCount > 0 && (totalSamplesMap.get(c) ?? 0) / sampleCount * 100 >= MIN_PERCENT);
|
|
3450
|
+
const label = trunc(fnName, 30).padEnd(30);
|
|
3451
|
+
const ms = parseFloat(((hasChildren ? total : self) / (sampleCount || 1) * durationMs).toFixed(1));
|
|
3452
|
+
const pct = hasChildren ? totalPct : selfPct;
|
|
3453
|
+
lines.push(`${indent}${hasChildren ? "\u25BC" : "\u25A0"} ${label} ${pct.toFixed(1).padStart(5)}% ${barPct(pct)} ${ms}ms ${hasChildren ? "total" : "self"}`);
|
|
3454
|
+
for (const c of node.children ?? [])
|
|
3455
|
+
renderNode(c, depth + 1);
|
|
3456
|
+
}
|
|
3457
|
+
if (profile.nodes.length > 0)
|
|
3458
|
+
renderNode(profile.nodes[0].id, 0);
|
|
3459
|
+
else
|
|
3460
|
+
lines.push("(no profile data)");
|
|
3461
|
+
lines.push("");
|
|
3462
|
+
lines.push("=== Ranked by Self Time ===");
|
|
3463
|
+
if (topFunctions.length === 0) {
|
|
3464
|
+
lines.push("(no data)");
|
|
3465
|
+
} else {
|
|
3466
|
+
const hdr = ` # ${"Function".padEnd(30)} ${"Self%".padStart(6)} ${"Self ms".padStart(8)} ${"Total%".padStart(6)} ${"Total ms".padStart(9)} Location`;
|
|
3467
|
+
lines.push(hdr);
|
|
3468
|
+
lines.push("-".repeat(hdr.length));
|
|
3469
|
+
topFunctions.forEach((f, i) => lines.push(` ${String(i + 1).padStart(2)} ${trunc(f.functionName, 30).padEnd(30)} ${`${f.selfPercent}%`.padStart(6)} ${`${f.selfMs}ms`.padStart(8)} ${`${f.totalPercent}%`.padStart(6)} ${`${f.totalMs}ms`.padStart(9)} ${f.url ? `${f.url}:${f.lineNumber}` : "(unknown)"}`));
|
|
3470
|
+
}
|
|
3471
|
+
return lines.join(`
|
|
3472
|
+
`);
|
|
3473
|
+
}
|
|
3474
|
+
function buildDevToolsChart(profile) {
|
|
3475
|
+
const lines = [];
|
|
3476
|
+
const totalDuration = profile.reduce((s, c) => s + c.duration, 0);
|
|
3477
|
+
lines.push("=== React DevTools Profile ===");
|
|
3478
|
+
lines.push(`${profile.length} commit${profile.length !== 1 ? "s" : ""} | ${totalDuration.toFixed(1)}ms total`);
|
|
3479
|
+
lines.push("");
|
|
3480
|
+
const byName = new Map;
|
|
3481
|
+
for (const commit of profile) {
|
|
3482
|
+
for (const comp of commit.components) {
|
|
3483
|
+
const entry = byName.get(comp.name) ?? { totalActual: 0, totalSelf: 0, commits: 0 };
|
|
3484
|
+
entry.totalActual += comp.actualMs;
|
|
3485
|
+
entry.totalSelf += comp.selfMs;
|
|
3486
|
+
entry.commits++;
|
|
3487
|
+
byName.set(comp.name, entry);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
const sorted = [...byName.entries()].map(([name, s]) => ({ name, ...s, avgActual: s.totalActual / s.commits, avgSelf: s.totalSelf / s.commits })).sort((a, b) => b.totalActual - a.totalActual);
|
|
3491
|
+
const maxTotal = sorted[0]?.totalActual ?? 1;
|
|
3492
|
+
lines.push("=== Components by Total Actual Duration ===");
|
|
3493
|
+
const hdr = ` # ${"Component".padEnd(30)} ${"Commits".padStart(8)} ${"Total".padStart(9)} ${"Avg".padStart(8)} ${"Self avg".padStart(9)} Chart`;
|
|
3494
|
+
lines.push(hdr);
|
|
3495
|
+
lines.push("-".repeat(hdr.length + BAR_WIDTH));
|
|
3496
|
+
sorted.forEach((s, i) => lines.push(` ${String(i + 1).padStart(2)} ${trunc(s.name, 30).padEnd(30)} ${String(s.commits).padStart(8)} ${`${s.totalActual.toFixed(1)}ms`.padStart(9)} ${`${s.avgActual.toFixed(1)}ms`.padStart(8)} ${`${s.avgSelf.toFixed(1)}ms`.padStart(9)} ${bar(s.totalActual, maxTotal)}`));
|
|
3497
|
+
return lines.join(`
|
|
3498
|
+
`);
|
|
3499
|
+
}
|
|
3500
|
+
function buildRenderChart(renders) {
|
|
3501
|
+
const lines = [];
|
|
3502
|
+
const sorted = [...renders].sort((a, b) => b.actualDuration - a.actualDuration);
|
|
3503
|
+
const maxActual = sorted[0]?.actualDuration ?? 1;
|
|
3504
|
+
lines.push("=== React Renders \u2014 Ranked by Actual Duration ===");
|
|
3505
|
+
const hdr = ` # ${"Component".padEnd(26)} ${"Phase".padEnd(14)} ${"Actual".padStart(8)} ${"Base".padStart(8)} Savings Chart`;
|
|
3506
|
+
lines.push(hdr);
|
|
3507
|
+
lines.push("-".repeat(hdr.length + BAR_WIDTH));
|
|
3508
|
+
sorted.forEach((r, i) => {
|
|
3509
|
+
const savings = memoSavings(r);
|
|
3510
|
+
lines.push(` ${String(i + 1).padStart(2)} ${trunc(r.id, 26).padEnd(26)} ${r.phase.padEnd(14)} ${`${r.actualDuration.toFixed(1)}ms`.padStart(8)} ${`${r.baseDuration.toFixed(1)}ms`.padStart(8)} ${savings !== null ? `${savings.toFixed(0)}%`.padStart(7) : " n/a"} ${bar(r.actualDuration, maxActual)}`);
|
|
3511
|
+
});
|
|
3512
|
+
const byId = new Map;
|
|
3513
|
+
for (const r of renders) {
|
|
3514
|
+
const e = byId.get(r.id) ?? { totalActual: 0, count: 0, phases: new Set };
|
|
3515
|
+
e.totalActual += r.actualDuration;
|
|
3516
|
+
e.count++;
|
|
3517
|
+
e.phases.add(r.phase);
|
|
3518
|
+
byId.set(r.id, e);
|
|
3519
|
+
}
|
|
3520
|
+
const summaries = [...byId.entries()].map(([id, s]) => ({ id, avg: s.totalActual / s.count, count: s.count, phases: [...s.phases].join(", ") })).sort((a, b) => b.avg - a.avg);
|
|
3521
|
+
lines.push("");
|
|
3522
|
+
lines.push("=== Summary by Component ===");
|
|
3523
|
+
const sHdr = ` ${"Component".padEnd(28)} ${"Renders".padStart(8)} ${"Avg actual".padStart(11)} Phases`;
|
|
3524
|
+
lines.push(sHdr);
|
|
3525
|
+
lines.push("-".repeat(sHdr.length));
|
|
3526
|
+
for (const s of summaries)
|
|
3527
|
+
lines.push(` ${trunc(s.id, 28).padEnd(28)} ${String(s.count).padStart(8)} ${`${s.avg.toFixed(1)}ms`.padStart(11)} ${s.phases}`);
|
|
3528
|
+
return lines.join(`
|
|
3529
|
+
`);
|
|
3530
|
+
}
|
|
3531
|
+
var DEVTOOLS_START_EXPR = `(function() {
|
|
3532
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
3533
|
+
if (!hook) return { error: 'no-hook' };
|
|
3534
|
+
|
|
3535
|
+
// Path 1: renderer has startProfiling (React DevTools backend connected)
|
|
3536
|
+
var count = 0;
|
|
3537
|
+
if (hook.renderers && typeof hook.renderers.forEach === 'function') {
|
|
3538
|
+
hook.renderers.forEach(function(r) {
|
|
3539
|
+
if (typeof r.startProfiling === 'function') { r.startProfiling(true); count++; }
|
|
3540
|
+
});
|
|
3541
|
+
}
|
|
3542
|
+
if (count > 0) return { ok: true, method: 'startProfiling', count: count };
|
|
3543
|
+
|
|
3544
|
+
// Path 2: patch onCommitFiberRoot \u2014 works without DevTools backend.
|
|
3545
|
+
// React calls this on every commit; fiber.actualDuration is tracked in dev builds.
|
|
3546
|
+
if (typeof hook.onCommitFiberRoot === 'undefined') return { error: 'no-hook-method' };
|
|
3547
|
+
var orig = hook.onCommitFiberRoot;
|
|
3548
|
+
var commits = [];
|
|
3549
|
+
hook.onCommitFiberRoot = function(rendererID, root, priorityLevel) {
|
|
3550
|
+
if (orig) try { orig.call(this, rendererID, root, priorityLevel); } catch(e) {}
|
|
3551
|
+
if (commits.length >= MAX_COMMITS) return;
|
|
3552
|
+
var components = [];
|
|
3553
|
+
var stack = root && root.current ? [root.current] : [];
|
|
3554
|
+
var depth = 0;
|
|
3555
|
+
while (stack.length > 0 && depth < 2000) {
|
|
3556
|
+
depth++;
|
|
3557
|
+
var fiber = stack.pop();
|
|
3558
|
+
var ad = fiber.actualDuration;
|
|
3559
|
+
if (typeof ad === 'number' && ad > 0.01) {
|
|
3560
|
+
var name = null;
|
|
3561
|
+
var type = fiber.type;
|
|
3562
|
+
if (typeof type === 'function') { name = type.displayName || type.name || null; }
|
|
3563
|
+
else if (typeof type === 'string') { name = type; }
|
|
3564
|
+
if (name) components.push({ name: name, actualMs: ad, selfMs: fiber.selfBaseDuration || 0 });
|
|
3565
|
+
}
|
|
3566
|
+
if (fiber.sibling) stack.push(fiber.sibling);
|
|
3567
|
+
if (fiber.child) stack.push(fiber.child);
|
|
3568
|
+
}
|
|
3569
|
+
if (components.length > 0) {
|
|
3570
|
+
components.sort(function(a, b) { return b.actualMs - a.actualMs; });
|
|
3571
|
+
commits.push({ timestamp: Date.now(), duration: (root && root.current && root.current.actualDuration) || 0, components: components });
|
|
3572
|
+
}
|
|
3573
|
+
};
|
|
3574
|
+
var MAX_COMMITS = 500;
|
|
3575
|
+
globalThis.__METRO_MCP_PROFILER__ = { commits: commits, orig: orig, max: MAX_COMMITS };
|
|
3576
|
+
return { ok: true, method: 'commit-hook', count: 1 };
|
|
3577
|
+
})()`;
|
|
3578
|
+
var DEVTOOLS_STOP_EXPR = `(function() {
|
|
3579
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
3580
|
+
|
|
3581
|
+
// Path 1: renderer had startProfiling (DevTools backend path)
|
|
3582
|
+
if (hook && hook.renderers && typeof hook.renderers.forEach === 'function') {
|
|
3583
|
+
var rdCommits = [];
|
|
3584
|
+
hook.renderers.forEach(function(renderer) {
|
|
3585
|
+
if (typeof renderer.stopProfiling !== 'function') return;
|
|
3586
|
+
renderer.stopProfiling();
|
|
3587
|
+
if (typeof renderer.getProfilingData !== 'function') return;
|
|
3588
|
+
var data; try { data = renderer.getProfilingData(); } catch(e) { return; }
|
|
3589
|
+
if (!data || !data.commitData) return;
|
|
3590
|
+
var infoMap = data.displayInfoMap;
|
|
3591
|
+
data.commitData.forEach(function(commit) {
|
|
3592
|
+
var components = [];
|
|
3593
|
+
var fiberActual = commit.fiberActualDurations;
|
|
3594
|
+
var fiberSelf = commit.fiberSelfDurations;
|
|
3595
|
+
if (fiberActual && typeof fiberActual.forEach === 'function') {
|
|
3596
|
+
fiberActual.forEach(function(actualMs, fiberId) {
|
|
3597
|
+
var selfMs = (fiberSelf && fiberSelf.get ? fiberSelf.get(fiberId) : 0) || 0;
|
|
3598
|
+
var name = String(fiberId);
|
|
3599
|
+
if (infoMap && infoMap.get) { var info = infoMap.get(fiberId); if (info) name = info.displayName || info.type || name; }
|
|
3600
|
+
if (actualMs > 0.01) components.push({ name: name, actualMs: actualMs, selfMs: selfMs });
|
|
3601
|
+
});
|
|
3602
|
+
}
|
|
3603
|
+
components.sort(function(a, b) { return b.actualMs - a.actualMs; });
|
|
3604
|
+
rdCommits.push({ timestamp: commit.timestamp || 0, duration: commit.duration || 0, components: components });
|
|
3605
|
+
});
|
|
3606
|
+
});
|
|
3607
|
+
if (rdCommits.length > 0) return rdCommits;
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
// Path 2: commit-hook patch
|
|
3611
|
+
var profiler = globalThis.__METRO_MCP_PROFILER__;
|
|
3612
|
+
if (!profiler) return null;
|
|
3613
|
+
if (hook) hook.onCommitFiberRoot = profiler.orig;
|
|
3614
|
+
var data = profiler.commits.slice();
|
|
3615
|
+
globalThis.__METRO_MCP_PROFILER__ = undefined;
|
|
3616
|
+
return data;
|
|
3617
|
+
})()`;
|
|
3618
|
+
var READ_RENDERS_EXPR = `(function() {
|
|
3619
|
+
var mcp = globalThis.__METRO_MCP__;
|
|
3620
|
+
return (mcp && Array.isArray(mcp.renders)) ? mcp.renders.slice() : null;
|
|
3621
|
+
})()`;
|
|
3622
|
+
var READ_AND_CLEAR_EXPR = `(function() {
|
|
3623
|
+
var mcp = globalThis.__METRO_MCP__;
|
|
3624
|
+
if (!mcp || !Array.isArray(mcp.renders)) return null;
|
|
3625
|
+
var data = mcp.renders.slice();
|
|
3626
|
+
if (typeof mcp.clearRenders === 'function') mcp.clearRenders();
|
|
3627
|
+
return data;
|
|
3628
|
+
})()`;
|
|
3629
|
+
var NOT_SETUP_MSG = 'No render data available. Add <Profiler id="..." onRender={trackRender}> to your app and import trackRender from metro-mcp/client.';
|
|
3630
|
+
var CONSOLE_PROFILE_TITLE = "metro-mcp";
|
|
3631
|
+
var profilerPlugin = definePlugin({
|
|
3632
|
+
name: "profiler",
|
|
3633
|
+
version: "0.1.0",
|
|
3634
|
+
description: "CPU profiling via React DevTools hook (primary) or CDP Profiler domain, plus React render tracking",
|
|
3635
|
+
async setup(ctx) {
|
|
3636
|
+
let profilingMode = null;
|
|
3637
|
+
let lastCpuProfile = null;
|
|
3638
|
+
let lastCpuAnalysis = null;
|
|
3639
|
+
let lastDevToolsProfile = null;
|
|
3640
|
+
const profilerConfig = ctx.config.profiler;
|
|
3641
|
+
const newArchitecture = profilerConfig?.newArchitecture ?? true;
|
|
3642
|
+
function isFuseboxTarget() {
|
|
3643
|
+
return ctx.cdp.getTarget()?.reactNative?.capabilities?.prefersFuseboxFrontend === true;
|
|
3644
|
+
}
|
|
3645
|
+
function shouldSkipCdpFallback() {
|
|
3646
|
+
return newArchitecture || isFuseboxTarget();
|
|
3647
|
+
}
|
|
3648
|
+
let pendingConsoleProfile = null;
|
|
3649
|
+
if (!newArchitecture) {
|
|
3650
|
+
ctx.cdp.on("Profiler.consoleProfileFinished", (params) => {
|
|
3651
|
+
const { title, profile } = params;
|
|
3652
|
+
if (title === CONSOLE_PROFILE_TITLE && pendingConsoleProfile) {
|
|
3653
|
+
clearTimeout(pendingConsoleProfile.timer);
|
|
3654
|
+
pendingConsoleProfile.resolve(profile);
|
|
3655
|
+
pendingConsoleProfile = null;
|
|
3656
|
+
}
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
async function buildFlamegraphText() {
|
|
3660
|
+
const sections = [];
|
|
3661
|
+
if (lastDevToolsProfile && lastDevToolsProfile.length > 0) {
|
|
3662
|
+
sections.push(buildDevToolsChart(lastDevToolsProfile));
|
|
3663
|
+
} else if (lastCpuProfile && lastCpuAnalysis) {
|
|
3664
|
+
sections.push(buildCpuFlamegraph(lastCpuProfile, lastCpuAnalysis));
|
|
3665
|
+
} else {
|
|
3666
|
+
sections.push("(no profile \u2014 call start_profiling, interact, then stop_profiling)");
|
|
3667
|
+
}
|
|
3668
|
+
sections.push("");
|
|
3669
|
+
try {
|
|
3670
|
+
const raw = await ctx.evalInApp(READ_RENDERS_EXPR);
|
|
3671
|
+
sections.push(raw && raw.length > 0 ? buildRenderChart(raw) : `=== React Renders ===
|
|
3672
|
+
${raw === null ? NOT_SETUP_MSG : "No renders recorded yet."}`);
|
|
3673
|
+
} catch {
|
|
3674
|
+
sections.push(`=== React Renders ===
|
|
3675
|
+
${NOT_SETUP_MSG}`);
|
|
3676
|
+
}
|
|
3677
|
+
return sections.join(`
|
|
3678
|
+
`);
|
|
3679
|
+
}
|
|
3680
|
+
ctx.registerTool("start_profiling", {
|
|
3681
|
+
description: "Start profiling the running React Native app. " + "Primary path: injects into the React DevTools hook (__REACT_DEVTOOLS_GLOBAL_HOOK__) via evalInApp \u2014 " + "captures all component render durations without requiring <Profiler> wrappers, works on all architectures. " + "Fallback (legacy arch only): CDP Profiler domain for JS CPU call-graph sampling. " + "Perform the interaction you want to measure, then call stop_profiling.",
|
|
3682
|
+
parameters: z19.object({
|
|
3683
|
+
samplingInterval: z19.number().int().min(100).max(1e5).default(1000).describe("CDP fallback only: sampling interval in microseconds (default 1000).")
|
|
3684
|
+
}),
|
|
3685
|
+
handler: async ({ samplingInterval }) => {
|
|
3686
|
+
if (profilingMode !== null)
|
|
3687
|
+
return "A profiling session is already active. Call stop_profiling first.";
|
|
3688
|
+
try {
|
|
3689
|
+
const result = await ctx.evalInApp(DEVTOOLS_START_EXPR);
|
|
3690
|
+
if (result?.ok) {
|
|
3691
|
+
profilingMode = "devtools-hook";
|
|
3692
|
+
const method = result.method === "commit-hook" ? "fiber commit hook (no DevTools backend required)" : `renderer.startProfiling (${result.count} renderer${result.count !== 1 ? "s" : ""})`;
|
|
3693
|
+
return `Profiling started via ${method}. Perform the interaction you want to measure, then call stop_profiling.`;
|
|
3694
|
+
}
|
|
3695
|
+
if (result?.error === "no-hook") {
|
|
3696
|
+
ctx.logger.debug("React DevTools hook not found, trying CDP fallback");
|
|
3697
|
+
} else if (result?.error === "no-hook-method") {
|
|
3698
|
+
ctx.logger.debug("DevTools hook found but onCommitFiberRoot unavailable, trying CDP fallback");
|
|
3699
|
+
}
|
|
3700
|
+
} catch (e) {
|
|
3701
|
+
ctx.logger.debug(`DevTools hook injection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3702
|
+
}
|
|
3703
|
+
if (shouldSkipCdpFallback()) {
|
|
3704
|
+
return "CPU profiling unavailable: __REACT_DEVTOOLS_GLOBAL_HOOK__ is not present in this JS environment. " + `This can happen if the app has not yet rendered or is running in a context where React is not active.
|
|
3705
|
+
|
|
3706
|
+
` + "Try calling any tool that interacts with the app first (e.g. get_component_tree), then retry start_profiling.";
|
|
3707
|
+
}
|
|
3708
|
+
try {
|
|
3709
|
+
await ctx.cdp.send("Profiler.enable");
|
|
3710
|
+
await ctx.cdp.send("Profiler.setSamplingInterval", { interval: samplingInterval });
|
|
3711
|
+
await ctx.cdp.send("Profiler.start");
|
|
3712
|
+
profilingMode = "cdp";
|
|
3713
|
+
return `Profiling started via CDP (sampling every ${samplingInterval} \xB5s). Perform the interaction you want to measure, then call stop_profiling.`;
|
|
3714
|
+
} catch (cdpErr) {
|
|
3715
|
+
const msg = cdpErr instanceof Error ? cdpErr.message : String(cdpErr);
|
|
3716
|
+
if (!msg.includes("Unsupported method") && !msg.includes("not supported")) {
|
|
3717
|
+
return `Failed to start profiling: ${msg}`;
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
try {
|
|
3721
|
+
await ctx.evalInApp(`console.profile(${JSON.stringify(CONSOLE_PROFILE_TITLE)})`);
|
|
3722
|
+
profilingMode = "console";
|
|
3723
|
+
return "Profiling started via console.profile(). Perform the interaction you want to measure, then call stop_profiling.";
|
|
3724
|
+
} catch (consoleErr) {
|
|
3725
|
+
return `Failed to start profiling: all paths exhausted \u2014 ${consoleErr instanceof Error ? consoleErr.message : String(consoleErr)}`;
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
});
|
|
3729
|
+
ctx.registerTool("stop_profiling", {
|
|
3730
|
+
description: "Stop profiling and return an analysis of the captured data. " + "DevTools hook mode: returns top components by total render duration across all commits. " + "CDP mode: returns top JS functions by self time and total time. " + "Must call start_profiling first.",
|
|
3731
|
+
parameters: z19.object({
|
|
3732
|
+
topN: z19.number().int().min(1).max(100).default(20).describe("Number of top entries to return."),
|
|
3733
|
+
includeNative: z19.boolean().default(false).describe("CDP mode only: include native/internal Hermes frames.")
|
|
3734
|
+
}),
|
|
3735
|
+
handler: async ({ topN, includeNative }) => {
|
|
3736
|
+
if (profilingMode === null)
|
|
3737
|
+
return "No profiling session in progress. Call start_profiling first.";
|
|
3738
|
+
try {
|
|
3739
|
+
if (profilingMode === "devtools-hook") {
|
|
3740
|
+
const raw = await ctx.evalInApp(DEVTOOLS_STOP_EXPR);
|
|
3741
|
+
profilingMode = null;
|
|
3742
|
+
if (!raw || raw.length === 0) {
|
|
3743
|
+
return { mode: "devtools-hook", commitCount: 0, message: "No commits recorded \u2014 profiling window may be too short." };
|
|
3744
|
+
}
|
|
3745
|
+
lastDevToolsProfile = raw;
|
|
3746
|
+
const byName = new Map;
|
|
3747
|
+
for (const commit of raw) {
|
|
3748
|
+
for (const comp of commit.components) {
|
|
3749
|
+
const e = byName.get(comp.name) ?? { totalActual: 0, totalSelf: 0, commits: 0 };
|
|
3750
|
+
e.totalActual += comp.actualMs;
|
|
3751
|
+
e.totalSelf += comp.selfMs;
|
|
3752
|
+
e.commits++;
|
|
3753
|
+
byName.set(comp.name, e);
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
const topComponents = [...byName.entries()].map(([name, s]) => ({ name, commits: s.commits, totalActualMs: parseFloat(s.totalActual.toFixed(2)), avgActualMs: parseFloat((s.totalActual / s.commits).toFixed(2)), avgSelfMs: parseFloat((s.totalSelf / s.commits).toFixed(2)) })).sort((a, b) => b.totalActualMs - a.totalActualMs).slice(0, topN);
|
|
3757
|
+
const totalDuration = raw.reduce((s, c) => s + c.duration, 0);
|
|
3758
|
+
return { mode: "devtools-hook", commitCount: raw.length, totalDurationMs: parseFloat(totalDuration.toFixed(2)), topComponents };
|
|
3759
|
+
}
|
|
3760
|
+
let profile;
|
|
3761
|
+
if (profilingMode === "cdp") {
|
|
3762
|
+
const result = await ctx.cdp.send("Profiler.stop");
|
|
3763
|
+
await ctx.cdp.send("Profiler.disable").catch(() => {});
|
|
3764
|
+
profile = result.profile;
|
|
3765
|
+
} else {
|
|
3766
|
+
const profilePromise = new Promise((resolve, reject) => {
|
|
3767
|
+
const timer = setTimeout(() => {
|
|
3768
|
+
pendingConsoleProfile = null;
|
|
3769
|
+
reject(new Error("Timed out waiting for profileEnd data (10s)."));
|
|
3770
|
+
}, 1e4);
|
|
3771
|
+
pendingConsoleProfile = { resolve, reject, timer };
|
|
3772
|
+
});
|
|
3773
|
+
await ctx.evalInApp(`console.profileEnd(${JSON.stringify(CONSOLE_PROFILE_TITLE)})`);
|
|
3774
|
+
profile = await profilePromise;
|
|
3775
|
+
}
|
|
3776
|
+
profilingMode = null;
|
|
3777
|
+
lastCpuProfile = profile;
|
|
3778
|
+
lastCpuAnalysis = analyzeCpuProfile(profile, topN, includeNative);
|
|
3779
|
+
const { durationMs, sampleCount, topFunctions } = lastCpuAnalysis;
|
|
3780
|
+
if (sampleCount === 0)
|
|
3781
|
+
return { mode: "cdp", durationMs, sampleCount: 0, message: "No samples collected.", topFunctions: [] };
|
|
3782
|
+
return {
|
|
3783
|
+
mode: "cdp",
|
|
3784
|
+
durationMs,
|
|
3785
|
+
sampleCount,
|
|
3786
|
+
samplingRateMs: parseFloat((durationMs / sampleCount).toFixed(3)),
|
|
3787
|
+
topFunctions: topFunctions.map((f) => ({
|
|
3788
|
+
functionName: f.functionName,
|
|
3789
|
+
location: f.url ? `${f.url}:${f.lineNumber}` : "(unknown)",
|
|
3790
|
+
selfTime: `${f.selfMs}ms (${f.selfPercent}%)`,
|
|
3791
|
+
totalTime: `${f.totalMs}ms (${f.totalPercent}%)`
|
|
3792
|
+
}))
|
|
3793
|
+
};
|
|
3794
|
+
} catch (err) {
|
|
3795
|
+
profilingMode = null;
|
|
3796
|
+
return `Failed to stop profiling: ${err instanceof Error ? err.message : String(err)}`;
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
});
|
|
3800
|
+
ctx.registerTool("get_profile_status", {
|
|
3801
|
+
description: "Check whether profiling is active, which mode is in use, and whether a previous profile is available.",
|
|
3802
|
+
parameters: z19.object({}),
|
|
3803
|
+
handler: async () => ({
|
|
3804
|
+
isProfiling: profilingMode !== null,
|
|
3805
|
+
profilingMode,
|
|
3806
|
+
hasDevToolsProfile: lastDevToolsProfile !== null,
|
|
3807
|
+
hasCpuProfile: lastCpuProfile !== null,
|
|
3808
|
+
lastDevToolsCommits: lastDevToolsProfile?.length ?? null,
|
|
3809
|
+
lastCpuDurationMs: lastCpuProfile ? parseFloat(((lastCpuProfile.endTime - lastCpuProfile.startTime) / 1000).toFixed(2)) : null,
|
|
3810
|
+
newArchitectureMode: newArchitecture
|
|
3811
|
+
})
|
|
3812
|
+
});
|
|
3813
|
+
ctx.registerTool("get_flamegraph", {
|
|
3814
|
+
description: "Return the current profiling results as a human-readable text chart. " + "Shows React DevTools component profile (if captured), CPU flamegraph (if CDP profile captured), " + "and React render data from <Profiler> components (if set up). " + "Call stop_profiling first to populate the profile data.",
|
|
3815
|
+
parameters: z19.object({}),
|
|
3816
|
+
handler: buildFlamegraphText
|
|
3817
|
+
});
|
|
3818
|
+
ctx.registerTool("get_react_renders", {
|
|
3819
|
+
description: "Read React render timing data collected via <Profiler onRender={trackRender}>. " + "Returns all recorded renders sorted by actualDuration descending, with memoization savings from baseDuration. " + "Requires importing trackRender from metro-mcp/client. Use clear=true to reset the buffer.",
|
|
3820
|
+
parameters: z19.object({
|
|
3821
|
+
clear: z19.boolean().default(false).describe("Clear the render buffer after reading.")
|
|
3822
|
+
}),
|
|
3823
|
+
handler: async ({ clear }) => {
|
|
3824
|
+
try {
|
|
3825
|
+
const raw = await ctx.evalInApp(clear ? READ_AND_CLEAR_EXPR : READ_RENDERS_EXPR);
|
|
3826
|
+
if (!raw)
|
|
3827
|
+
return NOT_SETUP_MSG;
|
|
3828
|
+
if (raw.length === 0)
|
|
3829
|
+
return clear ? "Render buffer cleared (was already empty)." : "No renders recorded yet.";
|
|
3830
|
+
return [...raw].sort((a, b) => b.actualDuration - a.actualDuration).map((r) => ({
|
|
3831
|
+
id: r.id,
|
|
3832
|
+
phase: r.phase,
|
|
3833
|
+
actualDuration: parseFloat(r.actualDuration.toFixed(2)),
|
|
3834
|
+
baseDuration: parseFloat(r.baseDuration.toFixed(2)),
|
|
3835
|
+
memoSavingsPercent: memoSavings(r),
|
|
3836
|
+
startTime: r.startTime,
|
|
3837
|
+
commitTime: r.commitTime
|
|
3838
|
+
}));
|
|
3839
|
+
} catch (err) {
|
|
3840
|
+
return `Failed to read render data: ${err instanceof Error ? err.message : String(err)}`;
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
});
|
|
3844
|
+
ctx.registerResource("metro://profiler/flamegraph", {
|
|
3845
|
+
name: "profiler-flamegraph",
|
|
3846
|
+
description: "Human-readable profiling output: React DevTools component chart or CPU flamegraph, plus React render chart from <Profiler> components.",
|
|
3847
|
+
mimeType: "text/plain",
|
|
3848
|
+
handler: buildFlamegraphText
|
|
3849
|
+
});
|
|
3850
|
+
ctx.registerResource("metro://profiler/data", {
|
|
3851
|
+
name: "profiler-data",
|
|
3852
|
+
description: "Raw JSON profiling data: React DevTools commit data or CDP Profile object, plus React render records.",
|
|
3853
|
+
mimeType: "application/json",
|
|
3854
|
+
handler: async () => {
|
|
3855
|
+
let renders = [];
|
|
3856
|
+
try {
|
|
3857
|
+
const raw = await ctx.evalInApp(READ_RENDERS_EXPR);
|
|
3858
|
+
if (Array.isArray(raw))
|
|
3859
|
+
renders = raw;
|
|
3860
|
+
} catch {}
|
|
3861
|
+
return JSON.stringify({
|
|
3862
|
+
mode: lastDevToolsProfile ? "devtools-hook" : lastCpuProfile ? "cdp" : null,
|
|
3863
|
+
devtools: lastDevToolsProfile ?? null,
|
|
3864
|
+
cpu: lastCpuProfile && lastCpuAnalysis ? {
|
|
3865
|
+
durationMs: lastCpuAnalysis.durationMs,
|
|
3866
|
+
sampleCount: lastCpuAnalysis.sampleCount,
|
|
3867
|
+
nodes: lastCpuProfile.nodes,
|
|
3868
|
+
samples: lastCpuProfile.samples,
|
|
3869
|
+
timeDeltas: lastCpuProfile.timeDeltas,
|
|
3870
|
+
analysis: { topFunctions: lastCpuAnalysis.topFunctions }
|
|
3871
|
+
} : null,
|
|
3872
|
+
renders: renders.map((r) => ({ ...r, memoSavingsPercent: memoSavings(r) }))
|
|
3873
|
+
}, null, 2);
|
|
3874
|
+
}
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
});
|
|
3878
|
+
|
|
3352
3879
|
// src/plugins/prompts.ts
|
|
3353
3880
|
var promptsPlugin = definePlugin({
|
|
3354
3881
|
name: "prompts",
|
|
@@ -3390,17 +3917,27 @@ var promptsPlugin = definePlugin({
|
|
|
3390
3917
|
]
|
|
3391
3918
|
});
|
|
3392
3919
|
ctx.registerPrompt("debug-performance", {
|
|
3393
|
-
description: "
|
|
3920
|
+
description: "Profile JS CPU usage and React render performance, then summarize findings with a flamegraph",
|
|
3394
3921
|
handler: async () => [
|
|
3395
3922
|
{
|
|
3396
3923
|
role: "user",
|
|
3397
3924
|
content: `I need to analyze my React Native app's performance. Please:
|
|
3398
|
-
1.
|
|
3399
|
-
2.
|
|
3400
|
-
3.
|
|
3401
|
-
4.
|
|
3402
|
-
5.
|
|
3403
|
-
|
|
3925
|
+
1. Check the current profiler status (get_profile_status)
|
|
3926
|
+
2. Clear any existing React render data (get_react_renders with clear=true)
|
|
3927
|
+
3. Start CPU profiling (start_profiling with default samplingInterval)
|
|
3928
|
+
4. Tell me to perform the interaction I want to profile, then wait for me to confirm it's done
|
|
3929
|
+
5. After I confirm:
|
|
3930
|
+
a. Stop CPU profiling and get the analysis (stop_profiling)
|
|
3931
|
+
b. Read React render timings (get_react_renders)
|
|
3932
|
+
c. Read the flamegraph resource (metro://profiler/flamegraph) for a combined visual breakdown
|
|
3933
|
+
d. Check for slow network requests (search_network \u2014 look for responses > 1s)
|
|
3934
|
+
e. Check console logs for perf warnings (get_console_logs with search="slow" or "perf")
|
|
3935
|
+
6. Summarize:
|
|
3936
|
+
- Top JS CPU hotspots by self time \u2014 which function and file is burning the most CPU
|
|
3937
|
+
- Slowest React component renders and whether memoization (memo/useMemo) is helping \u2014 compare actualDuration vs baseDuration
|
|
3938
|
+
- Components that re-render frequently (high count in the summary)
|
|
3939
|
+
- Any slow network requests contributing to perceived slowness
|
|
3940
|
+
- Concrete, prioritised recommendations`
|
|
3404
3941
|
}
|
|
3405
3942
|
]
|
|
3406
3943
|
});
|
|
@@ -3503,6 +4040,7 @@ var BUILT_IN_PLUGINS = [
|
|
|
3503
4040
|
commandsPlugin,
|
|
3504
4041
|
maestroPlugin,
|
|
3505
4042
|
appiumPlugin,
|
|
4043
|
+
profilerPlugin,
|
|
3506
4044
|
promptsPlugin
|
|
3507
4045
|
];
|
|
3508
4046
|
async function startServer(config) {
|
|
@@ -3526,7 +4064,7 @@ async function startServer(config) {
|
|
|
3526
4064
|
cdp: cdpClient,
|
|
3527
4065
|
registerTool: (name, toolConfig) => {
|
|
3528
4066
|
try {
|
|
3529
|
-
mcpServer.tool(name, toolConfig.description, toolConfig.parameters instanceof
|
|
4067
|
+
mcpServer.tool(name, toolConfig.description, toolConfig.parameters instanceof z20.ZodObject ? toolConfig.parameters.shape : { input: toolConfig.parameters }, async (args) => {
|
|
3530
4068
|
try {
|
|
3531
4069
|
const result = await toolConfig.handler(args);
|
|
3532
4070
|
const content = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
@@ -3557,7 +4095,7 @@ async function startServer(config) {
|
|
|
3557
4095
|
const argsShape = {};
|
|
3558
4096
|
if (promptConfig.arguments) {
|
|
3559
4097
|
for (const arg of promptConfig.arguments) {
|
|
3560
|
-
argsShape[arg.name] = arg.required ?
|
|
4098
|
+
argsShape[arg.name] = arg.required ? z20.string() : z20.string().optional();
|
|
3561
4099
|
}
|
|
3562
4100
|
}
|
|
3563
4101
|
mcpServer.prompt(name, promptConfig.description, argsShape, async (args) => {
|