@buoy-gg/mcp 4.0.1 → 5.0.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.
@@ -0,0 +1,2236 @@
1
+ // src/config.ts
2
+ import { DEFAULT_BROKER_PORT } from "@buoy-gg/sync-broker";
3
+ var BROKER_PORT = DEFAULT_BROKER_PORT;
4
+ var BROKER_URL = `http://localhost:${BROKER_PORT}`;
5
+ var DEFAULT_ACTION_TIMEOUT_MS = 15e3;
6
+ var DEFAULT_SNAPSHOT_TIMEOUT_MS = 5e3;
7
+ var DASHBOARD_NAME = "Dashboard";
8
+ var ALL_DEVICES = "All";
9
+
10
+ // src/sync/SyncClient.ts
11
+ import http from "http";
12
+ import { randomUUID } from "crypto";
13
+ import { io as ioClient } from "socket.io-client";
14
+ import {
15
+ createBroker
16
+ } from "@buoy-gg/sync-broker";
17
+ var SyncClient = class {
18
+ constructor(opts = {}) {
19
+ this.socket = null;
20
+ this.broker = null;
21
+ /** Latest capabilities announce per deviceId. */
22
+ this.capabilities = /* @__PURE__ */ new Map();
23
+ /** Latest device list from the broker's `all-devices-update`. */
24
+ this.devices = [];
25
+ /** Latest snapshot payload per `${deviceId}::${toolId}`. */
26
+ this.snapshots = /* @__PURE__ */ new Map();
27
+ /** Tools we've asked the broker to keep streaming, as `${deviceId}::${toolId}`. */
28
+ this.watching = /* @__PURE__ */ new Set();
29
+ /** In-flight actions awaiting a result, keyed by requestId. */
30
+ this.pendingActions = /* @__PURE__ */ new Map();
31
+ /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
32
+ this.snapshotWaiters = /* @__PURE__ */ new Map();
33
+ this.port = opts.port ?? BROKER_PORT;
34
+ this.url = opts.url ?? (opts.port ? `http://localhost:${opts.port}` : BROKER_URL);
35
+ this.actionTimeoutMs = opts.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
36
+ this.log = opts.log ?? ((msg) => process.stderr.write(`[buoy-mcp] ${msg}
37
+ `));
38
+ this.autoSpawn = opts.autoSpawnBroker ?? true;
39
+ }
40
+ // ----------------------------------------------------------------
41
+ // Lifecycle
42
+ // ----------------------------------------------------------------
43
+ /** Connect to a running broker, or spawn one in-process, then handshake. */
44
+ async start() {
45
+ const running = await probeBroker(this.url);
46
+ let mode = "connected";
47
+ if (!running) {
48
+ if (!this.autoSpawn) {
49
+ throw new Error(
50
+ `No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`
51
+ );
52
+ }
53
+ this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);
54
+ this.broker = createBroker({
55
+ port: this.port,
56
+ onError: (e) => this.log(`broker error: ${e.code ?? e.message}`)
57
+ });
58
+ await waitForBroker(this.url);
59
+ mode = "spawned";
60
+ } else {
61
+ this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`);
62
+ }
63
+ await this.connectSocket();
64
+ return mode;
65
+ }
66
+ connectSocket() {
67
+ return new Promise((resolve, reject) => {
68
+ const socket = ioClient(this.url, {
69
+ transports: ["websocket"],
70
+ query: { deviceName: DASHBOARD_NAME },
71
+ reconnection: true,
72
+ reconnectionDelay: 500,
73
+ timeout: 4e3
74
+ });
75
+ this.socket = socket;
76
+ let settled = false;
77
+ const onConnect = () => {
78
+ if (settled) return;
79
+ settled = true;
80
+ this.log("connected to broker");
81
+ resolve();
82
+ };
83
+ socket.on("connect", onConnect);
84
+ socket.once("connect_error", (err) => {
85
+ if (settled) return;
86
+ settled = true;
87
+ reject(new Error(`failed to connect to broker at ${this.url}: ${err.message}`));
88
+ });
89
+ socket.on("devtool-capabilities", (msg) => {
90
+ this.capabilities.set(msg.persistentDeviceId, msg);
91
+ });
92
+ socket.on("all-devices-update", (list) => {
93
+ if (Array.isArray(list)) this.devices = list;
94
+ });
95
+ socket.on("devtool-sync", (msg) => {
96
+ const key = snapKey(msg.persistentDeviceId, msg.toolId);
97
+ this.snapshots.set(key, msg.payload);
98
+ const waiters = this.snapshotWaiters.get(key);
99
+ if (waiters && waiters.length) {
100
+ this.snapshotWaiters.set(key, []);
101
+ for (const w of waiters) w(msg.payload);
102
+ }
103
+ });
104
+ socket.on("devtool-action-result", (msg) => {
105
+ const pending = this.pendingActions.get(msg.requestId);
106
+ if (!pending) return;
107
+ this.pendingActions.delete(msg.requestId);
108
+ clearTimeout(pending.timer);
109
+ pending.resolve(msg);
110
+ });
111
+ });
112
+ }
113
+ /** Disconnect and tear down a spawned broker, if any. */
114
+ async stop() {
115
+ for (const [, p] of this.pendingActions) {
116
+ clearTimeout(p.timer);
117
+ p.reject(new Error("SyncClient stopped"));
118
+ }
119
+ this.pendingActions.clear();
120
+ this.socket?.close();
121
+ this.socket = null;
122
+ if (this.broker) {
123
+ await this.broker.close();
124
+ this.broker = null;
125
+ }
126
+ }
127
+ // ----------------------------------------------------------------
128
+ // Queries
129
+ // ----------------------------------------------------------------
130
+ /** All devices the broker knows about, merged with their advertised tools. */
131
+ listDevices() {
132
+ return this.devices.filter((d) => d.deviceName !== DASHBOARD_NAME && d.deviceId).map((d) => {
133
+ const caps = this.capabilities.get(d.deviceId);
134
+ return {
135
+ deviceId: d.deviceId,
136
+ deviceName: d.deviceName,
137
+ platform: d.platform,
138
+ isConnected: d.isConnected !== false,
139
+ isPro: d.isPro === true,
140
+ tools: caps?.tools ?? []
141
+ };
142
+ });
143
+ }
144
+ /** Connected devices only. */
145
+ connectedDevices() {
146
+ return this.listDevices().filter((d) => d.isConnected);
147
+ }
148
+ /** Actions advertised for a tool, with the devices offering it. */
149
+ toolActions(toolId) {
150
+ return this.listDevices().map((d) => {
151
+ const tool = d.tools.find((t) => t.toolId === toolId);
152
+ return tool ? { deviceId: d.deviceId, deviceName: d.deviceName, actions: tool.actions } : null;
153
+ }).filter((x) => x !== null);
154
+ }
155
+ /**
156
+ * Resolve a concrete target deviceId. If `deviceId` is given it's used as-is.
157
+ * Otherwise, if exactly one device is connected, that one is chosen; if none
158
+ * or many, an informative error is thrown (callers can pass "All" explicitly).
159
+ */
160
+ resolveDevice(deviceId) {
161
+ if (deviceId) return deviceId;
162
+ const connected = this.connectedDevices();
163
+ if (connected.length === 1) return connected[0].deviceId;
164
+ if (connected.length === 0) {
165
+ throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.");
166
+ }
167
+ const list = connected.map((d) => `${d.deviceName} (${d.deviceId})`).join(", ");
168
+ throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${list}`);
169
+ }
170
+ /**
171
+ * Resolve a target device AND require it to be on Buoy Pro. The Buoy MCP is a
172
+ * Pro feature: data/action tools call this so a free app can't be driven
173
+ * through the MCP. The app's Keygen-validated license is the source of truth
174
+ * (the device reports it over the broker); this just reads that flag.
175
+ *
176
+ * `list_devices` deliberately does NOT call this — a free user can still see
177
+ * their device and that it's unlicensed, which explains why everything else
178
+ * is blocked.
179
+ */
180
+ requireProDevice(deviceId) {
181
+ const proMsg = (name) => `Buoy Pro is required to use the MCP.${name ? ` The connected app "${name}" is on the free tier.` : ""} Upgrade to Buoy Pro in the app to enable MCP access (run \`list_devices\` to check status).`;
182
+ if (deviceId === ALL_DEVICES) {
183
+ if (!this.connectedDevices().some((d) => d.isPro)) throw new Error(proMsg());
184
+ return ALL_DEVICES;
185
+ }
186
+ const target = this.resolveDevice(deviceId);
187
+ const dev = this.connectedDevices().find((d) => d.deviceId === target);
188
+ if (!dev?.isPro) throw new Error(proMsg(dev?.deviceName));
189
+ return target;
190
+ }
191
+ // ----------------------------------------------------------------
192
+ // Watch + snapshots
193
+ // ----------------------------------------------------------------
194
+ ensureWatching(toolId, targetDeviceId) {
195
+ const key = snapKey(targetDeviceId, toolId);
196
+ if (this.watching.has(key)) return;
197
+ this.watching.add(key);
198
+ this.socket?.emit("devtool-watch", { toolId, targetDeviceId, watching: true });
199
+ }
200
+ /** Stop streaming a tool (releases the device-side backpressure). */
201
+ unwatch(toolId, targetDeviceId) {
202
+ const key = snapKey(targetDeviceId, toolId);
203
+ if (!this.watching.delete(key)) return;
204
+ this.socket?.emit("devtool-watch", { toolId, targetDeviceId, watching: false });
205
+ }
206
+ /** Last snapshot received for a tool/device, if any. */
207
+ cachedSnapshot(toolId, deviceId) {
208
+ return this.snapshots.get(snapKey(deviceId, toolId));
209
+ }
210
+ /**
211
+ * Get a tool's current state. Starts watching (so the device begins emitting),
212
+ * explicitly requests state, and resolves with the next snapshot. Falls back
213
+ * to the last cached snapshot if the request times out.
214
+ */
215
+ async getSnapshot(toolId, deviceId, opts = {}) {
216
+ const target = this.resolveDevice(deviceId);
217
+ const key = snapKey(target, toolId);
218
+ this.ensureWatching(toolId, target);
219
+ const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
220
+ this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
221
+ const payload = await next;
222
+ if (payload !== void 0) return { deviceId: target, payload };
223
+ const cached = this.snapshots.get(key);
224
+ if (cached !== void 0) return { deviceId: target, payload: cached };
225
+ throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
226
+ }
227
+ waitForSnapshot(key, timeoutMs) {
228
+ return new Promise((resolve) => {
229
+ const waiters = this.snapshotWaiters.get(key) ?? [];
230
+ const timer = setTimeout(() => {
231
+ const remaining = (this.snapshotWaiters.get(key) ?? []).filter((w) => w !== onSnap);
232
+ this.snapshotWaiters.set(key, remaining);
233
+ resolve(void 0);
234
+ }, timeoutMs);
235
+ const onSnap = (payload) => {
236
+ clearTimeout(timer);
237
+ resolve(payload);
238
+ };
239
+ waiters.push(onSnap);
240
+ this.snapshotWaiters.set(key, waiters);
241
+ });
242
+ }
243
+ // ----------------------------------------------------------------
244
+ // Actions
245
+ // ----------------------------------------------------------------
246
+ /**
247
+ * Invoke a remote action on a device and await its result. Pass deviceId to
248
+ * target one device, or "All" to broadcast (resolves with the first responder).
249
+ */
250
+ async callAction(toolId, action, params, deviceId, opts = {}) {
251
+ const target = deviceId === ALL_DEVICES ? ALL_DEVICES : this.resolveDevice(deviceId);
252
+ if (!this.socket) throw new Error("SyncClient is not connected");
253
+ const requestId = randomUUID();
254
+ const timeoutMs = opts.timeoutMs ?? this.actionTimeoutMs;
255
+ const result = await new Promise((resolve, reject) => {
256
+ const timer = setTimeout(() => {
257
+ this.pendingActions.delete(requestId);
258
+ reject(new Error(`Action "${toolId}.${action}" timed out after ${timeoutMs}ms`));
259
+ }, timeoutMs);
260
+ this.pendingActions.set(requestId, { resolve, reject, timer });
261
+ this.socket.emit("devtool-action", {
262
+ requestId,
263
+ toolId,
264
+ targetDeviceId: target,
265
+ action,
266
+ params: params ?? {}
267
+ });
268
+ });
269
+ if (!result.ok) {
270
+ throw new Error(result.error || `Action "${toolId}.${action}" failed on device`);
271
+ }
272
+ return result.result;
273
+ }
274
+ };
275
+ var snapKey = (deviceId, toolId) => `${deviceId}::${toolId}`;
276
+ function probeBroker(url, timeoutMs = 800) {
277
+ return new Promise((resolve) => {
278
+ const req = http.get(url, (res) => {
279
+ res.resume();
280
+ resolve((res.statusCode ?? 0) > 0);
281
+ });
282
+ req.setTimeout(timeoutMs, () => {
283
+ req.destroy();
284
+ resolve(false);
285
+ });
286
+ req.on("error", () => resolve(false));
287
+ });
288
+ }
289
+ async function waitForBroker(url, attempts = 20) {
290
+ for (let i = 0; i < attempts; i++) {
291
+ if (await probeBroker(url, 300)) return;
292
+ await delay(50);
293
+ }
294
+ throw new Error(`Spawned broker never became reachable at ${url}`);
295
+ }
296
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
297
+
298
+ // src/version.ts
299
+ var VERSION = "5.0.0" ? "5.0.0" : "0.0.0-dev";
300
+ var PACKAGE_NAME = "@buoy-gg/mcp";
301
+
302
+ // src/update/checkForUpdate.ts
303
+ import { promises as fs } from "fs";
304
+ import os from "os";
305
+ import path from "path";
306
+ var CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
307
+ var FETCH_TIMEOUT_MS = 1500;
308
+ var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
309
+ var CACHE_FILE = path.join(os.tmpdir(), "buoy-mcp-update-check.json");
310
+ function isNewer(a, b) {
311
+ const parse = (v) => v.split("-")[0].split(".").map((n) => Number(n) || 0);
312
+ const pa = parse(a);
313
+ const pb = parse(b);
314
+ for (let i = 0; i < 3; i++) {
315
+ const x = pa[i] ?? 0;
316
+ const y = pb[i] ?? 0;
317
+ if (x > y) return true;
318
+ if (x < y) return false;
319
+ }
320
+ return false;
321
+ }
322
+ async function readCache() {
323
+ try {
324
+ const raw = await fs.readFile(CACHE_FILE, "utf8");
325
+ const parsed = JSON.parse(raw);
326
+ if (typeof parsed.checkedAt === "number" && typeof parsed.latest === "string") {
327
+ return parsed;
328
+ }
329
+ } catch {
330
+ }
331
+ return null;
332
+ }
333
+ async function fetchLatest(now) {
334
+ const controller = new AbortController();
335
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
336
+ try {
337
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
338
+ if (!res.ok) return null;
339
+ const body = await res.json();
340
+ const latest = typeof body.version === "string" ? body.version : null;
341
+ if (latest) {
342
+ await fs.writeFile(CACHE_FILE, JSON.stringify({ checkedAt: now, latest })).catch(() => {
343
+ });
344
+ }
345
+ return latest;
346
+ } catch {
347
+ return null;
348
+ } finally {
349
+ clearTimeout(timer);
350
+ }
351
+ }
352
+ async function resolveLatest(now) {
353
+ const cache = await readCache();
354
+ if (cache && now - cache.checkedAt < CHECK_TTL_MS) return cache.latest;
355
+ return fetchLatest(now);
356
+ }
357
+ async function getUpdateNotice(now = Date.now()) {
358
+ if (VERSION === "0.0.0-dev") return null;
359
+ try {
360
+ const latest = await resolveLatest(now);
361
+ if (latest && isNewer(latest, VERSION)) {
362
+ return `A newer buoy-mcp is available (${latest}; you're on ${VERSION}). Update by re-running: npx -y ${PACKAGE_NAME}@latest init \u2014 then restart your editor.`;
363
+ }
364
+ } catch {
365
+ }
366
+ return null;
367
+ }
368
+
369
+ // src/tools/generic.ts
370
+ import { z } from "zod";
371
+ function json(value) {
372
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
373
+ }
374
+ function fail(message) {
375
+ return { isError: true, content: [{ type: "text", text: message }] };
376
+ }
377
+ function registerGenericTools(server, sync) {
378
+ server.registerTool(
379
+ "list_devices",
380
+ {
381
+ description: "List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",
382
+ inputSchema: {}
383
+ },
384
+ async () => {
385
+ const updateNotice = await getUpdateNotice();
386
+ const devices = sync.listDevices();
387
+ if (devices.length === 0) {
388
+ return json({
389
+ devices: [],
390
+ hint: "No devices connected. Open the app with Buoy devtools running, then retry.",
391
+ ...updateNotice ? { updateAvailable: updateNotice } : {}
392
+ });
393
+ }
394
+ const anyPro = devices.some((d) => d.isConnected && d.isPro);
395
+ return json({
396
+ devices,
397
+ ...anyPro ? {} : {
398
+ hint: "The Buoy MCP is a Pro feature \u2014 no connected device has an active Buoy Pro license, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them."
399
+ },
400
+ ...updateNotice ? { updateAvailable: updateNotice } : {}
401
+ });
402
+ }
403
+ );
404
+ server.registerTool(
405
+ "list_tool_actions",
406
+ {
407
+ description: "List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",
408
+ inputSchema: {
409
+ toolId: z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')
410
+ }
411
+ },
412
+ async ({ toolId }) => {
413
+ const offerings = sync.toolActions(toolId);
414
+ if (offerings.length === 0) {
415
+ return fail(
416
+ `No connected device exposes tool "${toolId}". Call list_devices to see available tools.`
417
+ );
418
+ }
419
+ return json({ toolId, offerings });
420
+ }
421
+ );
422
+ server.registerTool(
423
+ "get_snapshot",
424
+ {
425
+ description: "Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",
426
+ inputSchema: {
427
+ toolId: z.string().describe("The tool id to read state from."),
428
+ deviceId: z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
429
+ timeoutMs: z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")
430
+ }
431
+ },
432
+ async ({ toolId, deviceId, timeoutMs }) => {
433
+ try {
434
+ const target = sync.requireProDevice(deviceId);
435
+ const { payload } = await sync.getSnapshot(toolId, target, { timeoutMs });
436
+ return json({ toolId, deviceId: target, snapshot: payload });
437
+ } catch (e) {
438
+ return fail(errMsg(e));
439
+ }
440
+ }
441
+ );
442
+ server.registerTool(
443
+ "call_action",
444
+ {
445
+ description: "Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",
446
+ inputSchema: {
447
+ toolId: z.string().describe('The tool id, e.g. "perf-monitor".'),
448
+ action: z.string().describe('The action name, e.g. "startRecording", "clearAll".'),
449
+ params: z.record(z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),
450
+ deviceId: z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),
451
+ timeoutMs: z.number().optional().describe("Max time to wait for the device's result.")
452
+ }
453
+ },
454
+ async ({ toolId, action, params, deviceId, timeoutMs }) => {
455
+ try {
456
+ const target = sync.requireProDevice(deviceId);
457
+ const result = await sync.callAction(toolId, action, params, target, { timeoutMs });
458
+ return json({ toolId, action, ok: true, result: result ?? null });
459
+ } catch (e) {
460
+ return fail(errMsg(e));
461
+ }
462
+ }
463
+ );
464
+ }
465
+ function errMsg(e) {
466
+ return e instanceof Error ? e.message : String(e);
467
+ }
468
+
469
+ // src/tools/packs/perfRanking.ts
470
+ var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
471
+ var FALLBACK = {
472
+ perCaseDurationMs: 5e3,
473
+ settleMs: 600,
474
+ navTimeoutMs: 5e3,
475
+ runsPerCase: 3,
476
+ coolDownMs: 2e3,
477
+ discardWarmupRuns: 0
478
+ };
479
+ function pick(override, saved, fallback) {
480
+ return override ?? saved ?? fallback;
481
+ }
482
+ function buildAutomationConfig(input, base = {}) {
483
+ const warnings = [];
484
+ const targetRoute = (input.targetRoute || "").trim();
485
+ if (!targetRoute) throw new Error("targetRoute is required (the screen to benchmark).");
486
+ let bounceRoute = (input.bounceRoute || "").trim();
487
+ if (!bounceRoute) bounceRoute = targetRoute === "/" ? "/_buoy_bounce" : "/";
488
+ if (bounceRoute === targetRoute) {
489
+ throw new Error(
490
+ `bounceRoute ("${bounceRoute}") must differ from targetRoute \u2014 it's the screen we bounce to so the test screen fully remounts each run.`
491
+ );
492
+ }
493
+ if (!Array.isArray(input.cases) || input.cases.length === 0) {
494
+ throw new Error("At least one case is required.");
495
+ }
496
+ const cases = input.cases.map((c, i) => {
497
+ const name = (c.name || "").trim() || `case ${i + 1}`;
498
+ return { id: `case-${i}`, name, params: c.params ?? {}, route: c.route };
499
+ });
500
+ const runsPerCase = clamp(Math.round(pick(input.runsPerCase, base.runsPerCase, FALLBACK.runsPerCase)), 1, 10);
501
+ const coolDownMs = clamp(Math.round(pick(input.coolDownMs, base.coolDownMs, FALLBACK.coolDownMs)), 0, 3e4);
502
+ const discardWarmupRuns = clamp(
503
+ Math.round(pick(input.discardWarmupRuns, base.discardWarmupRuns, FALLBACK.discardWarmupRuns)),
504
+ 0,
505
+ runsPerCase - 1
506
+ );
507
+ const shuffleCases = input.shuffleCases ?? base.shuffleCases ?? cases.length > 3;
508
+ if (cases.length > 3 && shuffleCases === false) {
509
+ warnings.push(
510
+ "shuffleCases is off with >3 cases \u2014 later cases may be thermally throttled, biasing results. Consider enabling it."
511
+ );
512
+ }
513
+ if (runsPerCase === 1) {
514
+ warnings.push("runsPerCase=1 \u2014 no median/variance; a single noisy run can mislead. 3+ is recommended.");
515
+ }
516
+ const config = {
517
+ targetRoute,
518
+ bounceRoute,
519
+ perCaseDurationMs: clamp(
520
+ Math.round(pick(input.perCaseDurationMs, base.perCaseDurationMs, FALLBACK.perCaseDurationMs)),
521
+ 1e3,
522
+ 6e4
523
+ ),
524
+ settleMs: clamp(Math.round(pick(input.settleMs, base.settleMs, FALLBACK.settleMs)), 0, 1e4),
525
+ navTimeoutMs: clamp(Math.round(pick(input.navTimeoutMs, base.navTimeoutMs, FALLBACK.navTimeoutMs)), 1e3, 3e4),
526
+ cases,
527
+ runsPerCase,
528
+ coolDownMs,
529
+ discardWarmupRuns,
530
+ shuffleCases,
531
+ reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false
532
+ };
533
+ return { config, warnings };
534
+ }
535
+ function describeBenchmarkSettings(config) {
536
+ const secs = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(ms % 1e3 ? 1 : 0)}s` : `${ms}ms`;
537
+ const runs = config.runsPerCase ?? 1;
538
+ const estMs = estimateBatchMs(config);
539
+ const estMin = estMs / 6e4;
540
+ const est = estMin >= 1 ? `~${estMin.toFixed(1)} min` : `~${Math.round(estMs / 1e3)}s`;
541
+ const parts = [
542
+ `**${secs(config.perCaseDurationMs)} measured per page**`,
543
+ runs > 1 ? `**${runs} runs each** (median taken)` : `**1 run each** (no median \u2014 noisier)`,
544
+ config.reloadBetweenCases ? "**full reload between pages** (no state leaks across tests)" : "no reload between pages",
545
+ config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order"
546
+ ];
547
+ const perPageMs = config.settleMs + config.perCaseDurationMs + (config.coolDownMs ?? 0) + 1800;
548
+ return `${parts.join(", ")}. That's ~${secs(perPageMs)} wall-clock per page; for ${config.cases.length} page(s) \xD7 ${runs} run(s), **${est}** total.`;
549
+ }
550
+ function estimateBatchMs(config) {
551
+ const NAV_OVERHEAD = 1800;
552
+ const perRun = config.settleMs + config.perCaseDurationMs + config.coolDownMs + NAV_OVERHEAD;
553
+ let total = config.cases.length * (config.runsPerCase ?? 1) * perRun;
554
+ if (config.reloadBetweenCases) total += config.cases.length * 3e3;
555
+ return total;
556
+ }
557
+ function computeFlags(r) {
558
+ const flags = [];
559
+ const max = r.deviceMaxRefreshRate;
560
+ if (r.uiFps != null && max && r.uiFps < max * 0.95) {
561
+ flags.push("UI thread drops frames");
562
+ } else if (r.uiJank != null && r.uiJank >= 2) {
563
+ flags.push("UI jank spikes");
564
+ }
565
+ if (r.cpu != null && r.cpu >= 90) flags.push("CPU near cap");
566
+ if (r.jsJank != null && r.jsJank >= 5) flags.push("JS thread stalls");
567
+ return flags;
568
+ }
569
+ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
570
+ const entries = index.filter((e) => e.batchId === batchId);
571
+ const failureByIndex = new Map(failures.map((f) => [f.index, f.reason]));
572
+ const indexes = casesOrder ? casesOrder.map((_, i) => i) : Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort((a, b) => a - b);
573
+ const medianForCase = (batchIndex) => {
574
+ const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex);
575
+ if (runs.length === 0) return void 0;
576
+ return runs.find((e) => e.isMedianRun === true) ?? runs.find((e) => e.runIndex === 0 || e.runIndex === void 0) ?? runs[0];
577
+ };
578
+ const num = (v) => typeof v === "number" ? v : null;
579
+ const rows = indexes.map((batchIndex) => {
580
+ const median = medianForCase(batchIndex);
581
+ const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex).length;
582
+ const caseName = casesOrder?.[batchIndex]?.name ?? median?.name ?? `case ${batchIndex + 1}`;
583
+ const failureReason = failureByIndex.get(batchIndex) ?? median?.batchFailureReason;
584
+ const failed = Boolean(failureReason) || !median;
585
+ const base = {
586
+ batchIndex,
587
+ caseName,
588
+ params: median?.params ?? {},
589
+ runs,
590
+ failed,
591
+ failureReason: failed ? failureReason ?? "no run recorded" : void 0,
592
+ uiFps: num(median?.uiFpsAvg),
593
+ jsFps: num(median?.jsFpsAvg),
594
+ cpu: num(median?.cpuAvg),
595
+ memMb: num(median?.memMaxMb),
596
+ jsJank: num(median?.jsJankFrames),
597
+ uiJank: num(median?.uiJankFrames),
598
+ deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
599
+ deltaUiFps: null,
600
+ deltaJsFps: null
601
+ };
602
+ return { ...base, flags: failed ? [] : computeFlags(base) };
603
+ });
604
+ const baseline = rows[0];
605
+ if (baseline) {
606
+ for (const r of rows) {
607
+ if (r === baseline) continue;
608
+ if (r.uiFps != null && baseline.uiFps != null) r.deltaUiFps = round(r.uiFps - baseline.uiFps, 1);
609
+ if (r.jsFps != null && baseline.jsFps != null) r.deltaJsFps = round(r.jsFps - baseline.jsFps, 1);
610
+ }
611
+ }
612
+ const live = rows.filter((r) => !r.failed);
613
+ const leaderBy = (sel, better) => {
614
+ let best;
615
+ let bestV = NaN;
616
+ for (const r of live) {
617
+ const v = sel(r);
618
+ if (v == null) continue;
619
+ if (best === void 0 || better(v, bestV)) {
620
+ best = r;
621
+ bestV = v;
622
+ }
623
+ }
624
+ return best?.caseName;
625
+ };
626
+ const totalJank = (r) => r.jsJank == null && r.uiJank == null ? null : (r.jsJank ?? 0) + (r.uiJank ?? 0);
627
+ const leaders = {
628
+ uiFps: leaderBy((r) => r.uiFps, (a, b) => a > b),
629
+ jsFps: leaderBy((r) => r.jsFps, (a, b) => a > b),
630
+ lowestCpu: leaderBy((r) => r.cpu, (a, b) => a < b),
631
+ lowestMem: leaderBy((r) => r.memMb, (a, b) => a < b),
632
+ fewestJank: leaderBy(totalJank, (a, b) => a < b)
633
+ };
634
+ const flagged = live.filter((r) => r.flags.length > 0).map((r) => ({ caseName: r.caseName, flags: r.flags }));
635
+ const balanced = live.filter((r) => r.flags.length === 0).sort(
636
+ (a, b) => (b.uiFps ?? 0) - (a.uiFps ?? 0) || (a.cpu ?? Infinity) - (b.cpu ?? Infinity) || (a.memMb ?? Infinity) - (b.memMb ?? Infinity)
637
+ ).map((r) => r.caseName);
638
+ return {
639
+ batchId,
640
+ baselineName: baseline?.caseName ?? null,
641
+ deviceMaxRefreshRate: live.find((r) => r.deviceMaxRefreshRate != null)?.deviceMaxRefreshRate ?? null,
642
+ rows,
643
+ leaders,
644
+ flagged,
645
+ balanced
646
+ };
647
+ }
648
+ function formatRankingMarkdown(ranking) {
649
+ const f = (v, d = 1) => v == null ? "\u2014" : v.toFixed(d);
650
+ const delta = (v) => v == null ? "" : v > 0 ? ` (+${v.toFixed(1)})` : ` (${v.toFixed(1)})`;
651
+ const lines = [];
652
+ lines.push(`### Benchmark batch \`${ranking.batchId}\``);
653
+ lines.push(
654
+ `**No single winner \u2014 weigh the trade-offs.** UI-thread health matters more than raw averages: a cheap run that drops UI frames is worse than a pricier one that holds them.`
655
+ );
656
+ lines.push("");
657
+ lines.push("| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank | Flags |");
658
+ lines.push("|---|------|-------:|-------:|------:|-------:|--------:|--------:|-------|");
659
+ for (const r of ranking.rows) {
660
+ if (r.failed) {
661
+ lines.push(`| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${r.failureReason}) |`);
662
+ continue;
663
+ }
664
+ const flagCell = r.flags.length ? `\u26A0\uFE0F ${r.flags.join("; ")}` : "\u2713 clean";
665
+ lines.push(
666
+ `| ${r.batchIndex} | ${r.caseName} | ${f(r.uiFps)}${delta(r.deltaUiFps)} | ${f(r.jsFps)}${delta(
667
+ r.deltaJsFps
668
+ )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} | ${flagCell} |`
669
+ );
670
+ }
671
+ lines.push("");
672
+ const L = ranking.leaders;
673
+ const leaderLine = [
674
+ L.uiFps && `UI FPS \u2192 **${L.uiFps}**`,
675
+ L.jsFps && `JS FPS \u2192 **${L.jsFps}**`,
676
+ L.lowestCpu && `lowest CPU \u2192 **${L.lowestCpu}**`,
677
+ L.lowestMem && `lowest mem \u2192 **${L.lowestMem}**`,
678
+ L.fewestJank && `fewest jank \u2192 **${L.fewestJank}**`
679
+ ].filter(Boolean).join(" \xB7 ");
680
+ if (leaderLine) lines.push(`**Per-metric best:** ${leaderLine}`);
681
+ lines.push(
682
+ ranking.balanced.length ? `**Balanced (caps nothing):** ${ranking.balanced.join(", ")}` : `**Balanced (caps nothing):** none \u2014 every case stressed at least one axis; pick by which trade-off you can accept.`
683
+ );
684
+ if (ranking.flagged.length) {
685
+ lines.push(
686
+ `**\u26A0\uFE0F At-risk:** ${ranking.flagged.map((x) => `${x.caseName} (${x.flags.join("; ")})`).join(" \xB7 ")}`
687
+ );
688
+ }
689
+ lines.push("");
690
+ lines.push(
691
+ `_Deltas vs baseline (case #0). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`
692
+ );
693
+ return lines.join("\n");
694
+ }
695
+ function compareReportStats(a, b) {
696
+ const ch = (x, y) => ({
697
+ a: { avg: round(x.avg, 1), p95: round(x.p95, 1) },
698
+ b: { avg: round(y.avg, 1), p95: round(y.p95, 1) },
699
+ deltaAvg: round(y.avg - x.avg, 1)
700
+ });
701
+ return {
702
+ a: a.name,
703
+ b: b.name,
704
+ uiFps: ch(a.stats.uiFps, b.stats.uiFps),
705
+ jsFps: ch(a.stats.jsFps, b.stats.jsFps),
706
+ cpuUsage: ch(a.stats.cpuUsage, b.stats.cpuUsage),
707
+ memoryUsage: ch(a.stats.memoryUsage, b.stats.memoryUsage),
708
+ jsJankFrames: { a: a.stats.jsJankFrames, b: b.stats.jsJankFrames, delta: b.stats.jsJankFrames - a.stats.jsJankFrames },
709
+ uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames }
710
+ };
711
+ }
712
+ function round(n, d) {
713
+ const p = Math.pow(10, d);
714
+ return Math.round(n * p) / p;
715
+ }
716
+
717
+ // src/tools/packs/perfMonitor.ts
718
+ import { z as z2 } from "zod";
719
+
720
+ // src/tools/packs/perfMonitorTypes.ts
721
+ var PERF_TOOL_ID = "perf-monitor";
722
+
723
+ // src/tools/packs/perfMonitor.ts
724
+ var POLL_INTERVAL_MS = 1500;
725
+ var delay2 = (ms) => new Promise((r) => setTimeout(r, ms));
726
+ var json2 = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
727
+ var text = (s) => ({ content: [{ type: "text", text: s }] });
728
+ var fail2 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
729
+ var errMsg2 = (e) => e instanceof Error ? e.message : String(e);
730
+ var caseSchema = z2.object({
731
+ name: z2.string().describe("Display name for this variant (becomes the saved run's name)."),
732
+ params: z2.record(z2.string()).optional().describe('Query params applied to the route for this case, e.g. { "renderer": "v2", "count": "100" }.'),
733
+ route: z2.string().optional().describe("Optional per-case route override; defaults to targetRoute.")
734
+ });
735
+ async function readDeviceSettings(sync, deviceId) {
736
+ try {
737
+ const cfg = await sync.callAction(PERF_TOOL_ID, "getAutomationConfig", {}, deviceId);
738
+ if (!cfg) return {};
739
+ const {
740
+ perCaseDurationMs,
741
+ settleMs,
742
+ navTimeoutMs,
743
+ runsPerCase,
744
+ coolDownMs,
745
+ discardWarmupRuns,
746
+ shuffleCases,
747
+ reloadBetweenCases
748
+ } = cfg;
749
+ return {
750
+ perCaseDurationMs,
751
+ settleMs,
752
+ navTimeoutMs,
753
+ runsPerCase,
754
+ coolDownMs,
755
+ discardWarmupRuns,
756
+ shuffleCases,
757
+ reloadBetweenCases
758
+ };
759
+ } catch {
760
+ return {};
761
+ }
762
+ }
763
+ async function readPerfSnapshot(sync, deviceId) {
764
+ const { payload } = await sync.getSnapshot(PERF_TOOL_ID, deviceId, { timeoutMs: 4e3 });
765
+ return payload;
766
+ }
767
+ function batchIdsIn(index) {
768
+ const ids = /* @__PURE__ */ new Set();
769
+ for (const e of index ?? []) if (e.batchId) ids.add(e.batchId);
770
+ return ids;
771
+ }
772
+ function runCountFor(index, batchId) {
773
+ return (index ?? []).filter((e) => e.batchId === batchId).length;
774
+ }
775
+ function newestBatchId(index) {
776
+ let best = null;
777
+ for (const e of index ?? []) {
778
+ if (e.batchId && (!best || e.createdAt > best.at)) best = { id: e.batchId, at: e.createdAt };
779
+ }
780
+ return best?.id ?? null;
781
+ }
782
+ function failuresFromIndex(index, batchId) {
783
+ const byCase = /* @__PURE__ */ new Map();
784
+ for (const e of index ?? []) {
785
+ if (e.batchId === batchId && e.batchFailureReason && !byCase.has(e.batchIndex ?? 0)) {
786
+ byCase.set(e.batchIndex ?? 0, e.batchFailureReason);
787
+ }
788
+ }
789
+ return [...byCase.entries()].map(([index2, reason]) => ({ index: index2, reason }));
790
+ }
791
+ async function waitForBatchByIndex(sync, deviceId, opts) {
792
+ const { preBatchIds, expectedRuns, maxWaitMs, log } = opts;
793
+ const start = Date.now();
794
+ let lastPhase = "";
795
+ let sawActive = false;
796
+ let idleStreak = 0;
797
+ while (Date.now() - start < maxWaitMs) {
798
+ let snap2;
799
+ try {
800
+ snap2 = await readPerfSnapshot(sync, deviceId);
801
+ } catch {
802
+ await delay2(POLL_INTERVAL_MS);
803
+ continue;
804
+ }
805
+ const index = snap2.index ?? [];
806
+ const phase = snap2.live?.automation?.phase ?? "idle";
807
+ const completed = snap2.live?.automationCompleted;
808
+ if (completed?.phase === "cancelled" && !preBatchIds.has(completed.batchId)) {
809
+ return { batchId: completed.batchId, failures: [], cancelled: true };
810
+ }
811
+ if (completed?.phase === "done" && !preBatchIds.has(completed.batchId)) {
812
+ return { batchId: completed.batchId, failures: completed.failures ?? failuresFromIndex(index, completed.batchId) };
813
+ }
814
+ if (phase !== "idle") sawActive = true;
815
+ if (phase !== lastPhase) {
816
+ lastPhase = phase;
817
+ log(`automation phase: ${phase}`);
818
+ }
819
+ const newId2 = [...batchIdsIn(index)].find((id) => !preBatchIds.has(id));
820
+ if (newId2) {
821
+ const count = runCountFor(index, newId2);
822
+ if (expectedRuns > 0 && count >= expectedRuns) {
823
+ log(`batch ${newId2} complete (${count}/${expectedRuns} runs saved)`);
824
+ return { batchId: newId2, failures: failuresFromIndex(index, newId2) };
825
+ }
826
+ if (phase === "idle" && sawActive && count > 0) {
827
+ if (++idleStreak >= 3) {
828
+ log(`batch ${newId2} settled (${count} runs, automation idle)`);
829
+ return { batchId: newId2, failures: failuresFromIndex(index, newId2) };
830
+ }
831
+ } else {
832
+ idleStreak = 0;
833
+ }
834
+ }
835
+ await delay2(POLL_INTERVAL_MS);
836
+ }
837
+ const snap = await readPerfSnapshot(sync, deviceId).catch(() => null);
838
+ const newId = snap ? [...batchIdsIn(snap.index)].find((id) => !preBatchIds.has(id)) : void 0;
839
+ if (newId && snap) return { batchId: newId, failures: failuresFromIndex(snap.index, newId), timedOut: true };
840
+ throw new Error(
841
+ `Batch did not start within ${Math.round(maxWaitMs / 1e3)}s (last phase: ${lastPhase || "idle"}). Check get_automation_status \u2014 the device may not have navigated.`
842
+ );
843
+ }
844
+ function registerPerfMonitorTools(server, sync) {
845
+ server.registerTool(
846
+ "run_benchmark_batch",
847
+ {
848
+ description: "Run a perf-monitor benchmark batch and return a RANKED comparison of all cases (winner by UI FPS). Give it a target route and a matrix of cases (each a name + query params that select a variant on that route); it navigates, records, and benchmarks each case on the device, then ranks them. This blocks until the batch completes (can be minutes for large batches). The app must have the target route render its variants from the params.",
849
+ inputSchema: {
850
+ targetRoute: z2.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),
851
+ cases: z2.array(caseSchema).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),
852
+ bounceRoute: z2.string().optional().describe('Screen to bounce to between runs so the test screen fully remounts. Defaults to "/" (or a stub if target is "/").'),
853
+ runsPerCase: z2.number().optional().describe("Runs per case; the median becomes the canonical result. Default 3 (1\u201310)."),
854
+ perCaseDurationMs: z2.number().optional().describe("How long to record each run. Default 5000."),
855
+ settleMs: z2.number().optional().describe("Idle gap after navigation before recording. Default 600."),
856
+ coolDownMs: z2.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),
857
+ navTimeoutMs: z2.number().optional().describe("Max wait for navigation per run. Default 5000."),
858
+ discardWarmupRuns: z2.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),
859
+ shuffleCases: z2.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias. Default on for >3 cases."),
860
+ reloadBetweenCases: z2.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),
861
+ deviceId: z2.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
862
+ maxWaitMs: z2.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")
863
+ }
864
+ },
865
+ async (args) => {
866
+ const log = (m) => process.stderr.write(`[buoy-mcp] ${m}
867
+ `);
868
+ try {
869
+ const deviceId = sync.requireProDevice(args.deviceId);
870
+ const base = await readDeviceSettings(sync, deviceId);
871
+ const { config, warnings } = buildAutomationConfig(args, base);
872
+ const estimateMs = estimateBatchMs(config);
873
+ const maxWaitMs = args.maxWaitMs ?? estimateMs * 3 + 6e4;
874
+ const expectedRuns = config.cases.length * (config.runsPerCase ?? 1);
875
+ await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, deviceId).catch(() => {
876
+ });
877
+ await sync.callAction(PERF_TOOL_ID, "setEnabled", { enabled: true }, deviceId).catch(() => {
878
+ });
879
+ await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, deviceId).catch(() => {
880
+ });
881
+ const before = await readPerfSnapshot(sync, deviceId);
882
+ const preBatchIds = batchIdsIn(before.index);
883
+ log(
884
+ `starting batch: ${config.cases.length} cases \xD7 ${config.runsPerCase} runs (~${Math.round(estimateMs / 1e3)}s estimated) on ${deviceId}`
885
+ );
886
+ await sync.callAction(PERF_TOOL_ID, "startAutomation", { config }, deviceId);
887
+ const outcome = await waitForBatchByIndex(sync, deviceId, {
888
+ preBatchIds,
889
+ expectedRuns,
890
+ maxWaitMs,
891
+ log
892
+ });
893
+ if (outcome.cancelled) {
894
+ await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, deviceId).catch(() => {
895
+ });
896
+ return fail2(`Batch ${outcome.batchId} was cancelled before completing.`);
897
+ }
898
+ await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, deviceId).catch(() => {
899
+ });
900
+ const snap = await readPerfSnapshot(sync, deviceId);
901
+ const ranking = rankBatchFromIndex(snap.index ?? [], outcome.batchId, config.cases, outcome.failures);
902
+ await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, deviceId).catch(() => {
903
+ });
904
+ await sync.callAction(PERF_TOOL_ID, "setEnabled", { enabled: false }, deviceId).catch(() => {
905
+ });
906
+ const md = formatRankingMarkdown(ranking);
907
+ const summary = {
908
+ batchId: outcome.batchId,
909
+ leaders: ranking.leaders,
910
+ balanced: ranking.balanced,
911
+ flagged: ranking.flagged,
912
+ rows: ranking.rows,
913
+ failures: outcome.failures,
914
+ warnings,
915
+ estimateMs,
916
+ ...outcome.timedOut ? { note: "Wait cap hit \u2014 results may be partial (some runs may still be saving)." } : {}
917
+ };
918
+ return text(`${md}
919
+
920
+ \`\`\`json
921
+ ${JSON.stringify(summary, null, 2)}
922
+ \`\`\``);
923
+ } catch (e) {
924
+ return fail2(errMsg2(e));
925
+ }
926
+ }
927
+ );
928
+ server.registerTool(
929
+ "get_automation_status",
930
+ {
931
+ description: "Get the perf-monitor automation batch status (current phase + last completed batch) without waiting.",
932
+ inputSchema: { deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).") }
933
+ },
934
+ async ({ deviceId }) => {
935
+ try {
936
+ const target = sync.requireProDevice(deviceId);
937
+ const snap = await readPerfSnapshot(sync, target);
938
+ return json2({
939
+ deviceId: target,
940
+ automation: snap.live?.automation ?? { phase: "idle" },
941
+ automationCompleted: snap.live?.automationCompleted ?? null
942
+ });
943
+ } catch (e) {
944
+ return fail2(errMsg2(e));
945
+ }
946
+ }
947
+ );
948
+ server.registerTool(
949
+ "wait_for_automation",
950
+ {
951
+ description: "Block until the in-flight perf-monitor batch finishes, then return its ranked comparison. Use this if a batch was started another way (e.g. on the device).",
952
+ inputSchema: {
953
+ deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected)."),
954
+ maxWaitMs: z2.number().optional().describe("Max time to wait. Default 600000 (10 min).")
955
+ }
956
+ },
957
+ async ({ deviceId, maxWaitMs }) => {
958
+ const log = (m) => process.stderr.write(`[buoy-mcp] ${m}
959
+ `);
960
+ const cap = maxWaitMs ?? 6e5;
961
+ try {
962
+ const target = sync.requireProDevice(deviceId);
963
+ const start = Date.now();
964
+ let sawActive = false;
965
+ let idleStreak = 0;
966
+ let lastCount = -1;
967
+ let resolved = null;
968
+ while (Date.now() - start < cap) {
969
+ let snap2;
970
+ try {
971
+ snap2 = await readPerfSnapshot(sync, target);
972
+ } catch {
973
+ await delay2(POLL_INTERVAL_MS);
974
+ continue;
975
+ }
976
+ const completed = snap2.live?.automationCompleted;
977
+ if (completed?.phase === "cancelled") return fail2(`Batch ${completed.batchId} was cancelled.`);
978
+ if (completed?.phase === "done") {
979
+ resolved = { batchId: completed.batchId, failures: completed.failures ?? failuresFromIndex(snap2.index, completed.batchId) };
980
+ break;
981
+ }
982
+ const phase = snap2.live?.automation?.phase ?? "idle";
983
+ if (phase !== "idle") sawActive = true;
984
+ const newest = newestBatchId(snap2.index);
985
+ if (phase === "idle" && newest) {
986
+ const count = runCountFor(snap2.index, newest);
987
+ if (count === lastCount) {
988
+ if (sawActive && ++idleStreak >= 3) {
989
+ resolved = { batchId: newest, failures: failuresFromIndex(snap2.index, newest) };
990
+ break;
991
+ }
992
+ if (!sawActive && Date.now() - start > 4e3) {
993
+ resolved = { batchId: newest, failures: failuresFromIndex(snap2.index, newest) };
994
+ break;
995
+ }
996
+ } else {
997
+ idleStreak = 0;
998
+ lastCount = count;
999
+ }
1000
+ } else {
1001
+ idleStreak = 0;
1002
+ }
1003
+ await delay2(POLL_INTERVAL_MS);
1004
+ }
1005
+ if (!resolved) return fail2("No automation batch completed within the wait window.");
1006
+ await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, target).catch(() => {
1007
+ });
1008
+ const snap = await readPerfSnapshot(sync, target);
1009
+ const ranking = rankBatchFromIndex(snap.index ?? [], resolved.batchId, void 0, resolved.failures);
1010
+ await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, target).catch(() => {
1011
+ });
1012
+ return text(`${formatRankingMarkdown(ranking)}
1013
+
1014
+ \`\`\`json
1015
+ ${JSON.stringify(ranking, null, 2)}
1016
+ \`\`\``);
1017
+ } catch (e) {
1018
+ return fail2(errMsg2(e));
1019
+ }
1020
+ }
1021
+ );
1022
+ server.registerTool(
1023
+ "get_batch_report",
1024
+ {
1025
+ description: "Re-rank a previously-run benchmark batch by its batchId (from the saved recordings index).",
1026
+ inputSchema: {
1027
+ batchId: z2.string().describe("The batch id to report on."),
1028
+ deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
1029
+ }
1030
+ },
1031
+ async ({ batchId, deviceId }) => {
1032
+ try {
1033
+ const target = sync.requireProDevice(deviceId);
1034
+ await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, target).catch(() => {
1035
+ });
1036
+ const snap = await readPerfSnapshot(sync, target);
1037
+ const entries = (snap.index ?? []).filter((e) => e.batchId === batchId);
1038
+ if (entries.length === 0) return fail2(`No saved runs found for batch "${batchId}".`);
1039
+ const ranking = rankBatchFromIndex(snap.index ?? [], batchId);
1040
+ return text(`${formatRankingMarkdown(ranking)}
1041
+
1042
+ \`\`\`json
1043
+ ${JSON.stringify(ranking, null, 2)}
1044
+ \`\`\``);
1045
+ } catch (e) {
1046
+ return fail2(errMsg2(e));
1047
+ }
1048
+ }
1049
+ );
1050
+ server.registerTool(
1051
+ "compare_reports",
1052
+ {
1053
+ description: "Compare two saved benchmark runs head-to-head (avg + p95 for FPS/CPU/memory, jank, with deltas).",
1054
+ inputSchema: {
1055
+ reportIdA: z2.string().describe("First (baseline) report id."),
1056
+ reportIdB: z2.string().describe("Second report id to compare against the baseline."),
1057
+ deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
1058
+ }
1059
+ },
1060
+ async ({ reportIdA, reportIdB, deviceId }) => {
1061
+ try {
1062
+ const target = sync.requireProDevice(deviceId);
1063
+ const [a, b] = await Promise.all([
1064
+ sync.callAction(PERF_TOOL_ID, "loadReport", { id: reportIdA }, target),
1065
+ sync.callAction(PERF_TOOL_ID, "loadReport", { id: reportIdB }, target)
1066
+ ]);
1067
+ if (!a) return fail2(`Report "${reportIdA}" not found.`);
1068
+ if (!b) return fail2(`Report "${reportIdB}" not found.`);
1069
+ return json2(
1070
+ compareReportStats(
1071
+ { name: a.metadata.name, stats: a.stats },
1072
+ { name: b.metadata.name, stats: b.stats }
1073
+ )
1074
+ );
1075
+ } catch (e) {
1076
+ return fail2(errMsg2(e));
1077
+ }
1078
+ }
1079
+ );
1080
+ server.registerTool(
1081
+ "get_benchmark_settings",
1082
+ {
1083
+ description: "Read the device's saved benchmark settings (per-page duration, runs, cooldown, reload, shuffle) in plain English with a time estimate. Call this before run_benchmark_batch so you can tell the user what the run will do \u2014 run_benchmark_batch inherits these unless you override them.",
1084
+ inputSchema: {
1085
+ pages: z2.number().optional().describe("How many pages/variants you plan to test, for a total-time estimate. Default 1."),
1086
+ deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
1087
+ }
1088
+ },
1089
+ async ({ pages, deviceId }) => {
1090
+ try {
1091
+ const target = sync.requireProDevice(deviceId);
1092
+ const settings = await readDeviceSettings(sync, target);
1093
+ const n = Math.max(1, Math.round(pages ?? 1));
1094
+ const { config } = buildAutomationConfig(
1095
+ { targetRoute: "/_preview", cases: Array.from({ length: n }, (_, i) => ({ name: `page ${i + 1}` })) },
1096
+ settings
1097
+ );
1098
+ const source = Object.keys(settings).length > 0 ? "this device's saved settings" : "default profile (none saved)";
1099
+ return text(
1100
+ `**Benchmark settings (${source}):** ${describeBenchmarkSettings(config)}
1101
+
1102
+ \`\`\`json
1103
+ ${JSON.stringify(settings, null, 2)}
1104
+ \`\`\``
1105
+ );
1106
+ } catch (e) {
1107
+ return fail2(errMsg2(e));
1108
+ }
1109
+ }
1110
+ );
1111
+ server.registerTool(
1112
+ "set_benchmark_settings",
1113
+ {
1114
+ description: `PERSIST benchmark settings on the device so they stick across runs. Only use when the user explicitly wants to save a change ("always", "save this"). For a one-off, pass overrides to run_benchmark_batch instead \u2014 don't change their saved profile.`,
1115
+ inputSchema: {
1116
+ perCaseDurationMs: z2.number().optional().describe("Recording length per page (ms)."),
1117
+ settleMs: z2.number().optional().describe("Idle after navigation before recording (ms)."),
1118
+ coolDownMs: z2.number().optional().describe("Idle between runs/cases (ms)."),
1119
+ navTimeoutMs: z2.number().optional().describe("Max wait for navigation per run (ms)."),
1120
+ runsPerCase: z2.number().optional().describe("Runs per page; median is the canonical result (1\u201310)."),
1121
+ discardWarmupRuns: z2.number().optional().describe("Discard first N runs of each page before the median."),
1122
+ shuffleCases: z2.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias."),
1123
+ reloadBetweenCases: z2.boolean().optional().describe("Fully reload JS between pages (needs <AutomationResumer/> mounted)."),
1124
+ deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
1125
+ }
1126
+ },
1127
+ async (args) => {
1128
+ try {
1129
+ const target = sync.requireProDevice(args.deviceId);
1130
+ const current = await sync.callAction(PERF_TOOL_ID, "getAutomationConfig", {}, target) ?? {};
1131
+ const keys = [
1132
+ "perCaseDurationMs",
1133
+ "settleMs",
1134
+ "coolDownMs",
1135
+ "navTimeoutMs",
1136
+ "runsPerCase",
1137
+ "discardWarmupRuns",
1138
+ "shuffleCases",
1139
+ "reloadBetweenCases"
1140
+ ];
1141
+ const overrides = {};
1142
+ for (const k of keys) {
1143
+ const v = args[k];
1144
+ if (v !== void 0) overrides[k] = v;
1145
+ }
1146
+ if (Object.keys(overrides).length === 0) {
1147
+ return fail2("No settings provided to save. Pass at least one field (e.g. perCaseDurationMs).");
1148
+ }
1149
+ const next = { ...current, ...overrides };
1150
+ const saved = await sync.callAction(PERF_TOOL_ID, "setAutomationConfig", { config: next }, target);
1151
+ const settings = await readDeviceSettings(sync, target);
1152
+ const { config } = buildAutomationConfig(
1153
+ { targetRoute: "/_preview", cases: [{ name: "page 1" }] },
1154
+ settings
1155
+ );
1156
+ return text(
1157
+ `Saved. **New benchmark settings:** ${describeBenchmarkSettings(config)}
1158
+
1159
+ \`\`\`json
1160
+ ${JSON.stringify(saved ?? settings, null, 2)}
1161
+ \`\`\``
1162
+ );
1163
+ } catch (e) {
1164
+ return fail2(errMsg2(e));
1165
+ }
1166
+ }
1167
+ );
1168
+ }
1169
+
1170
+ // src/tools/packs/events.ts
1171
+ import { z as z3 } from "zod";
1172
+ var EVENTS_TOOL_ID = "events";
1173
+ var text2 = (s) => ({ content: [{ type: "text", text: s }] });
1174
+ var fail3 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1175
+ var errMsg3 = (e) => e instanceof Error ? e.message : String(e);
1176
+ var SOURCE_ALIASES = {
1177
+ storage: ["storage-async", "storage-mmkv"],
1178
+ "react-query": ["react-query", "react-query-query", "react-query-mutation"]
1179
+ };
1180
+ function expandSources(sources) {
1181
+ if (!sources || sources.length === 0) return void 0;
1182
+ return [...new Set(sources.flatMap((s) => SOURCE_ALIASES[s] ?? [s]))];
1183
+ }
1184
+ function registerEventsTools(server, sync) {
1185
+ server.registerTool(
1186
+ "get_events",
1187
+ {
1188
+ description: "The UNIVERSAL, token-bounded activity reader for what's happening in the app across ALL Buoy tools. The events tool aggregates every source \u2014 network requests, redux/zustand/jotai actions, react-query query/mutation updates, storage writes, route changes, renders \u2014 so filter to one with `sources`: ['network'] lists HTTP requests, ['redux'] dispatched actions, ['react-query'] query updates, ['storage'] writes, ['route'] navigation. Returns a compact one-line summary per event (source \xB7 status \xB7 title \xB7 timing) by DEFAULT \u2014 not the heavy raw payloads \u2014 so it's cheap. Re-call with includeData:true, a bigger limit, or specific sources only when you need detail. PREFER this over get_snapshot (and over reading source code) for any 'what happened' / 'show me the requests/actions' question; get_snapshot is the raw heavy current-state escape hatch.",
1189
+ inputSchema: {
1190
+ limit: z3.number().optional().describe("Most-recent N events to include. Default 25."),
1191
+ sources: z3.array(z3.string()).optional().describe('Only these sources. Coarse names accepted: "network", "redux", "react-query", "storage", "route", "render", "zustand", "jotai". Omit for all.'),
1192
+ status: z3.enum(["all", "errors", "success", "pending"]).optional().describe("Filter by status. Default all."),
1193
+ includeData: z3.boolean().optional().describe("Include each event's full raw payload (HEAVY \u2014 network bodies, state trees). Default false (compact)."),
1194
+ format: z3.enum(["markdown", "json", "text", "mermaid"]).optional().describe("Output format. Default markdown."),
1195
+ preset: z3.enum(["llm", "bugReport", "json", "errors", "minimal", "mermaid"]).optional().describe("Start from a named Copy-Settings preset (then your other args override it)."),
1196
+ deviceId: z3.string().optional().describe("Target device id (auto-selected if one is connected).")
1197
+ }
1198
+ },
1199
+ async (args) => {
1200
+ try {
1201
+ const target = sync.requireProDevice(args.deviceId);
1202
+ const settings = {};
1203
+ const sources = expandSources(args.sources);
1204
+ if (sources) settings.filterSources = sources;
1205
+ if (args.status) settings.filterMode = args.status;
1206
+ if (args.includeData !== void 0) settings.includeEventData = args.includeData;
1207
+ if (args.format) settings.format = args.format === "text" ? "plaintext" : args.format;
1208
+ const res = await sync.callAction(
1209
+ EVENTS_TOOL_ID,
1210
+ "exportEvents",
1211
+ { preset: args.preset, settings, limit: args.limit ?? 25 },
1212
+ target
1213
+ );
1214
+ if (!res || typeof res.output !== "string") {
1215
+ return fail3(
1216
+ "No events result from the device. The events tool may be inactive, or the app needs a newer Buoy (get_events requires the exportEvents action)."
1217
+ );
1218
+ }
1219
+ const more = res.includedData ? "" : " \u2014 compact view; pass includeData:true, a specific source, or a bigger limit for full payloads";
1220
+ const note = `
1221
+
1222
+ _Showing ${res.returned} of ${res.totalAvailable} captured event(s)${more}._`;
1223
+ return text2((res.output || "(no events captured)") + note);
1224
+ } catch (e) {
1225
+ return fail3(errMsg3(e));
1226
+ }
1227
+ }
1228
+ );
1229
+ }
1230
+
1231
+ // src/tools/packs/reactQuery.ts
1232
+ import { z as z4 } from "zod";
1233
+ var RQ_TOOL_ID = "react-query";
1234
+ var text3 = (s) => ({ content: [{ type: "text", text: s }] });
1235
+ var fail4 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1236
+ var errMsg4 = (e) => e instanceof Error ? e.message : String(e);
1237
+ var STATUS_ICON = {
1238
+ fresh: "\u{1F7E2}",
1239
+ stale: "\u{1F7E1}",
1240
+ fetching: "\u{1F504}",
1241
+ error: "\u{1F534}",
1242
+ inactive: "\u26AA\uFE0F",
1243
+ paused: "\u23F8\uFE0F",
1244
+ disabled: "\u{1F6AB}"
1245
+ };
1246
+ function relTime(ts) {
1247
+ if (!ts) return "\u2014";
1248
+ const s = Math.round((Date.now() - ts) / 1e3);
1249
+ if (s < 60) return `${s}s ago`;
1250
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
1251
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
1252
+ return `${Math.round(s / 86400)}d ago`;
1253
+ }
1254
+ function registerReactQueryTools(server, sync) {
1255
+ server.registerTool(
1256
+ "get_react_query",
1257
+ {
1258
+ description: "Read the app's CURRENT React Query cache \u2014 every query with its status (fresh/stale/fetching/error), staleness, observer count, last-updated time, and any error. Compact by DEFAULT (no cached payloads) so it's token-cheap; pass includeData:true for the actual cached data, or staleOnly:true to see just stale queries. Use this for 'what's in the cache / why is this stale / which queries errored'. (For the history of query updates over time, use get_events with sources:['react-query'].) Each row's hash is what you pass to react_query_action.",
1259
+ inputSchema: {
1260
+ includeData: z4.boolean().optional().describe("Include each query's full cached data payload (HEAVY). Default false."),
1261
+ staleOnly: z4.boolean().optional().describe("Only stale queries. Default false."),
1262
+ limit: z4.number().optional().describe("Most-recently-updated N queries. Default 25."),
1263
+ deviceId: z4.string().optional().describe("Target device id (auto-selected if one is connected).")
1264
+ }
1265
+ },
1266
+ async (args) => {
1267
+ try {
1268
+ const target = sync.requireProDevice(args.deviceId);
1269
+ const res = await sync.callAction(
1270
+ RQ_TOOL_ID,
1271
+ "listQueries",
1272
+ { includeData: args.includeData ?? false, staleOnly: args.staleOnly ?? false, limit: args.limit ?? 25 },
1273
+ target
1274
+ );
1275
+ if (!res || !Array.isArray(res.queries)) {
1276
+ return fail4(
1277
+ "No react-query result from the device. The react-query tool may be inactive, or the app needs a newer Buoy (get_react_query requires the listQueries action)."
1278
+ );
1279
+ }
1280
+ if (res.queries.length === 0) {
1281
+ return text3(`React Query cache is empty (0 of ${res.total} queries${args.staleOnly ? " match staleOnly" : ""}).`);
1282
+ }
1283
+ const lines = res.queries.map((q, i) => {
1284
+ const icon = STATUS_ICON[q.status] ?? "\u2022";
1285
+ const base = `${i + 1}. ${icon} ${q.status} \xB7 \`${q.queryHash}\` \xB7 ${q.observers} obs \xB7 ${relTime(q.updatedAt)}`;
1286
+ const errSuffix = q.error ? ` \u2014 \u26A0\uFE0F ${q.error}` : "";
1287
+ const dataSuffix = res.includedData ? `
1288
+ data: ${JSON.stringify(q.data)}` : "";
1289
+ return base + errSuffix + dataSuffix;
1290
+ });
1291
+ const staleCount = res.queries.filter((q) => q.isStale).length;
1292
+ const header = `**React Query cache \u2014 ${res.total} queries (showing ${res.returned}, ${staleCount} stale):**`;
1293
+ const note = res.includedData ? "" : "\n\n_Compact view. Pass includeData:true for cached payloads, staleOnly:true to narrow. Act on one with react_query_action using the `hash` in backticks._";
1294
+ return text3(`${header}
1295
+
1296
+ ${lines.join("\n")}${note}`);
1297
+ } catch (e) {
1298
+ return fail4(errMsg4(e));
1299
+ }
1300
+ }
1301
+ );
1302
+ server.registerTool(
1303
+ "react_query_action",
1304
+ {
1305
+ description: "Mutate the app's React Query cache: invalidate / refetch / reset / remove a single query (by queryHash, from get_react_query), or clear the entire query cache. Use this to fix stale data, force a refetch, or reset state during debugging.",
1306
+ inputSchema: {
1307
+ action: z4.enum(["invalidate", "refetch", "reset", "remove", "clear"]).describe("invalidate/refetch/reset/remove a single query (needs queryHash); clear empties the whole query cache."),
1308
+ queryHash: z4.string().optional().describe("The target query's hash (from get_react_query, shown in backticks). Required for all actions except clear."),
1309
+ deviceId: z4.string().optional().describe("Target device id (auto-selected if one is connected).")
1310
+ }
1311
+ },
1312
+ async (args) => {
1313
+ try {
1314
+ const target = sync.requireProDevice(args.deviceId);
1315
+ if (args.action === "clear") {
1316
+ await sync.callAction(RQ_TOOL_ID, "clearQueryCache", {}, target);
1317
+ return text3("Cleared the entire React Query cache.");
1318
+ }
1319
+ if (!args.queryHash) {
1320
+ return fail4(`'${args.action}' needs a queryHash. Call get_react_query first and use a row's hash (in backticks).`);
1321
+ }
1322
+ await sync.callAction(RQ_TOOL_ID, args.action, { queryHash: args.queryHash }, target);
1323
+ return text3(`Ran ${args.action} on query \`${args.queryHash}\`.`);
1324
+ } catch (e) {
1325
+ return fail4(errMsg4(e));
1326
+ }
1327
+ }
1328
+ );
1329
+ }
1330
+
1331
+ // src/tools/packs/zustand.ts
1332
+ import { z as z5 } from "zod";
1333
+ var ZUSTAND_TOOL_ID = "zustand";
1334
+ var text4 = (s) => ({ content: [{ type: "text", text: s }] });
1335
+ var fail5 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1336
+ var errMsg5 = (e) => e instanceof Error ? e.message : String(e);
1337
+ function registerZustandTools(server, sync) {
1338
+ server.registerTool(
1339
+ "get_zustand_state",
1340
+ {
1341
+ description: "Read the app's CURRENT Zustand store state \u2014 each registered store with its name, change count, persistence, and (by default) just its top-level keys so it's token-cheap. Pass includeValues:true for the full state objects. Use this for 'what's in my zustand stores / what's the current count'. (For the history of state changes over time, use get_events with sources:['zustand'].)",
1342
+ inputSchema: {
1343
+ includeValues: z5.boolean().optional().describe("Include each store's full current state object (HEAVY). Default false (keys only)."),
1344
+ limit: z5.number().optional().describe("First N stores. Default all."),
1345
+ deviceId: z5.string().optional().describe("Target device id (auto-selected if one is connected).")
1346
+ }
1347
+ },
1348
+ async (args) => {
1349
+ try {
1350
+ const target = sync.requireProDevice(args.deviceId);
1351
+ const res = await sync.callAction(
1352
+ ZUSTAND_TOOL_ID,
1353
+ "listStores",
1354
+ { includeValues: args.includeValues ?? false, limit: args.limit },
1355
+ target
1356
+ );
1357
+ if (!res || !Array.isArray(res.stores)) {
1358
+ return fail5(
1359
+ "No zustand result from the device. The zustand tool may be inactive, or the app needs a newer Buoy (get_zustand_state requires the listStores action)."
1360
+ );
1361
+ }
1362
+ if (res.stores.length === 0) {
1363
+ return text4("No Zustand stores are registered (call watchStores to register them).");
1364
+ }
1365
+ const lines = res.stores.map((s, i) => {
1366
+ const persist = s.isPersisted ? ` \xB7 persisted${s.persistName ? ` (${s.persistName})` : ""}` : "";
1367
+ const body = res.includedValues ? `
1368
+ ${JSON.stringify(s.currentState)}` : ` \xB7 keys: ${(s.keys ?? []).join(", ") || "\u2014"}`;
1369
+ return `${i + 1}. **${s.name}** \xB7 ${s.changes} changes${persist}${body}`;
1370
+ });
1371
+ const note = res.includedValues ? "" : "\n\n_Compact view (keys only). Pass includeValues:true for full state. Mutate with zustand_action({store, state})._";
1372
+ return text4(`**Zustand stores \u2014 ${res.total} (showing ${res.returned}):**
1373
+
1374
+ ${lines.join("\n")}${note}`);
1375
+ } catch (e) {
1376
+ return fail5(errMsg5(e));
1377
+ }
1378
+ }
1379
+ );
1380
+ server.registerTool(
1381
+ "zustand_action",
1382
+ {
1383
+ description: "Set the state of a Zustand store (time-travel / reset / tweak during debugging). Targets a store by name (from get_zustand_state). By default replaces the whole state; pass replace:false to merge a partial.",
1384
+ inputSchema: {
1385
+ store: z5.string().describe("Store name to target (from get_zustand_state)."),
1386
+ state: z5.record(z5.string(), z5.unknown()).describe("The new state object."),
1387
+ replace: z5.boolean().optional().describe("Replace the whole state (default true) or merge a partial (false)."),
1388
+ deviceId: z5.string().optional().describe("Target device id (auto-selected if one is connected).")
1389
+ }
1390
+ },
1391
+ async (args) => {
1392
+ try {
1393
+ const target = sync.requireProDevice(args.deviceId);
1394
+ await sync.callAction(
1395
+ ZUSTAND_TOOL_ID,
1396
+ "setState",
1397
+ { storeName: args.store, state: args.state, replace: args.replace ?? true },
1398
+ target
1399
+ );
1400
+ return text4(`Set state on Zustand store "${args.store}" (${args.replace === false ? "merged" : "replaced"}).`);
1401
+ } catch (e) {
1402
+ return fail5(errMsg5(e));
1403
+ }
1404
+ }
1405
+ );
1406
+ }
1407
+
1408
+ // src/tools/packs/jotai.ts
1409
+ import { z as z6 } from "zod";
1410
+ var JOTAI_TOOL_ID = "jotai";
1411
+ var text5 = (s) => ({ content: [{ type: "text", text: s }] });
1412
+ var fail6 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1413
+ var errMsg6 = (e) => e instanceof Error ? e.message : String(e);
1414
+ function registerJotaiTools(server, sync) {
1415
+ server.registerTool(
1416
+ "get_jotai_state",
1417
+ {
1418
+ description: "Read the app's CURRENT Jotai atom values \u2014 each watched atom with its label, change count, and whether it's writable. Compact by DEFAULT (no values) so it's token-cheap; pass includeValues:true for the actual current values. Use this for 'what are my jotai atoms / what's the current value'. (For the history of atom changes over time, use get_events with sources:['jotai'].)",
1419
+ inputSchema: {
1420
+ includeValues: z6.boolean().optional().describe("Include each atom's current value (HEAVY). Default false."),
1421
+ limit: z6.number().optional().describe("First N atoms. Default all."),
1422
+ deviceId: z6.string().optional().describe("Target device id (auto-selected if one is connected).")
1423
+ }
1424
+ },
1425
+ async (args) => {
1426
+ try {
1427
+ const target = sync.requireProDevice(args.deviceId);
1428
+ const res = await sync.callAction(
1429
+ JOTAI_TOOL_ID,
1430
+ "listAtoms",
1431
+ { includeValues: args.includeValues ?? false, limit: args.limit },
1432
+ target
1433
+ );
1434
+ if (!res || !Array.isArray(res.atoms)) {
1435
+ return fail6(
1436
+ "No jotai result from the device. The jotai tool may be inactive, or the app needs a newer Buoy (get_jotai_state requires the listAtoms action)."
1437
+ );
1438
+ }
1439
+ if (res.atoms.length === 0) {
1440
+ return text5("No Jotai atoms are being watched (call watchAtoms to register them).");
1441
+ }
1442
+ const lines = res.atoms.map((a, i) => {
1443
+ const kind = a.writable ? "writable" : "read-only";
1444
+ const value = res.includedValues ? `
1445
+ ${JSON.stringify(a.currentValue)}` : "";
1446
+ return `${i + 1}. **${a.label}** \xB7 ${a.changes} changes \xB7 ${kind}${value}`;
1447
+ });
1448
+ const note = res.includedValues ? "" : "\n\n_Compact view. Pass includeValues:true for current values. Set a writable atom with jotai_action({atom, value})._";
1449
+ return text5(`**Jotai atoms \u2014 ${res.total} (showing ${res.returned}):**
1450
+
1451
+ ${lines.join("\n")}${note}`);
1452
+ } catch (e) {
1453
+ return fail6(errMsg6(e));
1454
+ }
1455
+ }
1456
+ );
1457
+ server.registerTool(
1458
+ "jotai_action",
1459
+ {
1460
+ description: "Set the value of a writable Jotai atom (by label, from get_jotai_state). Read-only/derived atoms cannot be set (you'll get a clear error). Use for tweaking state during debugging.",
1461
+ inputSchema: {
1462
+ atom: z6.string().describe("Atom label to set (from get_jotai_state; must be writable)."),
1463
+ value: z6.unknown().describe("The new value (any JSON \u2014 number, string, object, etc.)."),
1464
+ deviceId: z6.string().optional().describe("Target device id (auto-selected if one is connected).")
1465
+ }
1466
+ },
1467
+ async (args) => {
1468
+ try {
1469
+ const target = sync.requireProDevice(args.deviceId);
1470
+ await sync.callAction(JOTAI_TOOL_ID, "setAtom", { label: args.atom, value: args.value }, target);
1471
+ return text5(`Set Jotai atom "${args.atom}" to ${JSON.stringify(args.value)}.`);
1472
+ } catch (e) {
1473
+ return fail6(errMsg6(e));
1474
+ }
1475
+ }
1476
+ );
1477
+ }
1478
+
1479
+ // src/tools/packs/redux.ts
1480
+ import { z as z7 } from "zod";
1481
+ var REDUX_TOOL_ID = "redux";
1482
+ var text6 = (s) => ({ content: [{ type: "text", text: s }] });
1483
+ var fail7 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1484
+ var errMsg7 = (e) => e instanceof Error ? e.message : String(e);
1485
+ var NO_STORE = "No instrumented Redux store found. Open the app with Buoy redux devtools active (auto-instrumentation), then retry.";
1486
+ function registerReduxTools(server, sync) {
1487
+ server.registerTool(
1488
+ "get_redux_state",
1489
+ {
1490
+ description: "Read the app's CURRENT Redux state. Compact by DEFAULT \u2014 just the top-level slice names \u2014 so it's token-cheap; pass includeValues:true for the full state tree. Use this for 'what's in my redux store / current auth state'. (For the history of dispatched actions over time, use get_events with sources:['redux'].)",
1491
+ inputSchema: {
1492
+ includeValues: z7.boolean().optional().describe("Include the full current state tree (HEAVY). Default false (slice names only)."),
1493
+ deviceId: z7.string().optional().describe("Target device id (auto-selected if one is connected).")
1494
+ }
1495
+ },
1496
+ async (args) => {
1497
+ try {
1498
+ const target = sync.requireProDevice(args.deviceId);
1499
+ const res = await sync.callAction(
1500
+ REDUX_TOOL_ID,
1501
+ "getState",
1502
+ { includeValues: args.includeValues ?? false },
1503
+ target
1504
+ );
1505
+ if (!res) {
1506
+ return fail7(
1507
+ "No redux result from the device. The redux tool may be inactive, or the app needs a newer Buoy (get_redux_state requires the getState action)."
1508
+ );
1509
+ }
1510
+ if (!res.available) return text6(NO_STORE);
1511
+ const slices = res.slices.length ? res.slices.join(", ") : "(none)";
1512
+ if (args.includeValues) {
1513
+ return text6(`**Redux store \u2014 slices: ${slices}**
1514
+
1515
+ \`\`\`json
1516
+ ${JSON.stringify(res.state, null, 2)}
1517
+ \`\`\``);
1518
+ }
1519
+ return text6(
1520
+ `**Redux store \u2014 slices: ${slices}**
1521
+
1522
+ _Compact view. Pass includeValues:true for the full state tree. Dispatch with redux_dispatch({type, payload})._`
1523
+ );
1524
+ } catch (e) {
1525
+ return fail7(errMsg7(e));
1526
+ }
1527
+ }
1528
+ );
1529
+ server.registerTool(
1530
+ "redux_dispatch",
1531
+ {
1532
+ description: "Dispatch a Redux action to the app's store (e.g. to trigger a state change while debugging). Provide the action type and optional payload \u2014 they're sent as { type, payload }.",
1533
+ inputSchema: {
1534
+ type: z7.string().describe('The action type, e.g. "counter/increment" or "auth/logout".'),
1535
+ payload: z7.unknown().optional().describe("Optional action payload (any JSON)."),
1536
+ deviceId: z7.string().optional().describe("Target device id (auto-selected if one is connected).")
1537
+ }
1538
+ },
1539
+ async (args) => {
1540
+ try {
1541
+ const target = sync.requireProDevice(args.deviceId);
1542
+ const action = { type: args.type };
1543
+ if (args.payload !== void 0) action.payload = args.payload;
1544
+ const res = await sync.callAction(REDUX_TOOL_ID, "dispatch", { action }, target);
1545
+ if (!res || !res.dispatched) return fail7(NO_STORE);
1546
+ return text6(`Dispatched \`${args.type}\`${args.payload !== void 0 ? ` with payload ${JSON.stringify(args.payload)}` : ""}.`);
1547
+ } catch (e) {
1548
+ return fail7(errMsg7(e));
1549
+ }
1550
+ }
1551
+ );
1552
+ }
1553
+
1554
+ // src/tools/packs/storage.ts
1555
+ import { z as z8 } from "zod";
1556
+ var STORAGE_TOOL_ID = "storage";
1557
+ var text7 = (s) => ({ content: [{ type: "text", text: s }] });
1558
+ var fail8 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1559
+ var errMsg8 = (e) => e instanceof Error ? e.message : String(e);
1560
+ function truncate(v, n = 200) {
1561
+ if (v === null || v === void 0) return "(null)";
1562
+ const s = typeof v === "string" ? v : JSON.stringify(v);
1563
+ return s.length > n ? `${s.slice(0, n)}\u2026(+${s.length - n} chars)` : s;
1564
+ }
1565
+ function registerStorageTools(server, sync) {
1566
+ server.registerTool(
1567
+ "get_storage",
1568
+ {
1569
+ description: "Read the app's AsyncStorage keys. Compact by DEFAULT \u2014 keys only \u2014 so it's token-cheap; pass includeValues:true for the values (truncated), or a `pattern` substring to narrow. Buoy's own internal keys are already filtered out. Use for 'what's in storage / read this key'.",
1570
+ inputSchema: {
1571
+ pattern: z8.string().optional().describe("Only keys containing this substring (case-insensitive)."),
1572
+ includeValues: z8.boolean().optional().describe("Include each key's value (truncated). Default false (keys only)."),
1573
+ limit: z8.number().optional().describe("Max keys to return. Default 100."),
1574
+ deviceId: z8.string().optional().describe("Target device id (auto-selected if one is connected).")
1575
+ }
1576
+ },
1577
+ async (args) => {
1578
+ try {
1579
+ const target = sync.requireProDevice(args.deviceId);
1580
+ const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
1581
+ if (!Array.isArray(allKeys)) {
1582
+ return fail8("No storage result from the device. The storage tool may be inactive.");
1583
+ }
1584
+ const pattern = args.pattern?.toLowerCase();
1585
+ const matched = pattern ? allKeys.filter((k) => k.toLowerCase().includes(pattern)) : allKeys;
1586
+ const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1587
+ const keys = matched.slice(0, limit);
1588
+ if (keys.length === 0) {
1589
+ return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} (${allKeys.length} total).`);
1590
+ }
1591
+ let lines;
1592
+ if (args.includeValues) {
1593
+ const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys }, target);
1594
+ const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1595
+ lines = keys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1596
+ } else {
1597
+ lines = keys.map((k) => `- \`${k}\``);
1598
+ }
1599
+ const filteredNote = pattern ? ` matching "${args.pattern}"` : "";
1600
+ const header = `**Storage \u2014 ${allKeys.length} keys (showing ${keys.length}${filteredNote}):**`;
1601
+ const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify with storage_action._";
1602
+ return text7(`${header}
1603
+
1604
+ ${lines.join("\n")}${note}`);
1605
+ } catch (e) {
1606
+ return fail8(errMsg8(e));
1607
+ }
1608
+ }
1609
+ );
1610
+ server.registerTool(
1611
+ "storage_action",
1612
+ {
1613
+ description: "Modify the app's AsyncStorage: set a key, remove a key, or clear all app storage (Buoy's own devtool keys are preserved). Values are stored as strings \u2014 JSON-encode objects yourself.",
1614
+ inputSchema: {
1615
+ action: z8.enum(["set", "remove", "clear"]).describe("set/remove a key, or clear all app storage."),
1616
+ key: z8.string().optional().describe("The key (required for set and remove)."),
1617
+ value: z8.string().optional().describe("The string value to store (required for set; JSON-encode objects)."),
1618
+ deviceId: z8.string().optional().describe("Target device id (auto-selected if one is connected).")
1619
+ }
1620
+ },
1621
+ async (args) => {
1622
+ try {
1623
+ const target = sync.requireProDevice(args.deviceId);
1624
+ if (args.action === "clear") {
1625
+ await sync.callAction(STORAGE_TOOL_ID, "clearAppStorage", {}, target);
1626
+ return text7("Cleared all app storage (Buoy's own devtool keys preserved).");
1627
+ }
1628
+ if (!args.key) {
1629
+ return fail8(`'${args.action}' needs a key.`);
1630
+ }
1631
+ if (args.action === "remove") {
1632
+ await sync.callAction(STORAGE_TOOL_ID, "async.removeItem", { key: args.key }, target);
1633
+ return text7(`Removed storage key "${args.key}".`);
1634
+ }
1635
+ if (args.value === void 0) {
1636
+ return fail8("'set' needs a value (a string).");
1637
+ }
1638
+ await sync.callAction(STORAGE_TOOL_ID, "async.setItem", { key: args.key, value: args.value }, target);
1639
+ return text7(`Set storage key "${args.key}".`);
1640
+ } catch (e) {
1641
+ return fail8(errMsg8(e));
1642
+ }
1643
+ }
1644
+ );
1645
+ }
1646
+
1647
+ // src/tools/packs/routes.ts
1648
+ import { z as z9 } from "zod";
1649
+ var ROUTES_TOOL_ID = "route-events";
1650
+ var text8 = (s) => ({ content: [{ type: "text", text: s }] });
1651
+ var fail9 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1652
+ var errMsg9 = (e) => e instanceof Error ? e.message : String(e);
1653
+ function flattenRoutes(routes, acc = []) {
1654
+ for (const r of routes ?? []) {
1655
+ if (r.path) acc.push(r.path);
1656
+ if (r.children?.length) flattenRoutes(r.children, acc);
1657
+ }
1658
+ return acc;
1659
+ }
1660
+ function registerRoutesTools(server, sync) {
1661
+ server.registerTool(
1662
+ "get_routes",
1663
+ {
1664
+ description: "Read the app's CURRENT route + navigation stack (where the user is and how they got there). Compact by DEFAULT; pass includeSitemap:true to also list every route path the app defines (useful before navigate). Use for 'what screen am I on / what's the nav stack / what routes exist'. (For navigation history over time, use get_events with sources:['route'].)",
1665
+ inputSchema: {
1666
+ includeSitemap: z9.boolean().optional().describe("Also list all defined route paths (can be long). Default false."),
1667
+ deviceId: z9.string().optional().describe("Target device id (auto-selected if one is connected).")
1668
+ }
1669
+ },
1670
+ async (args) => {
1671
+ try {
1672
+ const target = sync.requireProDevice(args.deviceId);
1673
+ const { payload } = await sync.getSnapshot(ROUTES_TOOL_ID, target);
1674
+ const p = Array.isArray(payload) ? { events: payload } : payload ?? {};
1675
+ const stack = Array.isArray(p.stack) ? p.stack : [];
1676
+ const focused = stack.find((s) => s.isFocused) ?? stack[stack.length - 1];
1677
+ const current = focused?.pathname ?? "(unknown)";
1678
+ const parts = [`**Current route:** \`${current}\``];
1679
+ if (stack.length) {
1680
+ const stackLines = stack.map(
1681
+ (s, i) => `${i + 1}. \`${s.pathname ?? s.name ?? "?"}\`${s.isFocused ? " \u2190 focused" : ""}`
1682
+ );
1683
+ parts.push(`
1684
+ **Navigation stack** (top last):
1685
+ ${stackLines.join("\n")}`);
1686
+ }
1687
+ const allPaths = flattenRoutes(p.sitemap?.routes);
1688
+ if (args.includeSitemap) {
1689
+ const shown = allPaths.slice(0, 200);
1690
+ parts.push(
1691
+ `
1692
+ **Sitemap (${allPaths.length} routes${shown.length < allPaths.length ? `, showing ${shown.length}` : ""}):**
1693
+ ${shown.map((r) => `- \`${r}\``).join("\n")}`
1694
+ );
1695
+ } else {
1696
+ parts.push(`
1697
+ **Sitemap:** ${allPaths.length} routes _(pass includeSitemap:true to list them)_`);
1698
+ }
1699
+ parts.push("\n_Navigate with navigate({ path })._");
1700
+ return text8(parts.join("\n"));
1701
+ } catch (e) {
1702
+ return fail9(errMsg9(e));
1703
+ }
1704
+ }
1705
+ );
1706
+ server.registerTool(
1707
+ "navigate",
1708
+ {
1709
+ description: "Navigate the app to a route path (e.g. '/pokemon/25' or '/settings'). Use a concrete path \u2014 resolve any dynamic segments (like [id]) to real values first. See get_routes with includeSitemap:true for valid paths.",
1710
+ inputSchema: {
1711
+ path: z9.string().describe("The concrete path to navigate to, e.g. '/pokemon/25'."),
1712
+ deviceId: z9.string().optional().describe("Target device id (auto-selected if one is connected).")
1713
+ }
1714
+ },
1715
+ async (args) => {
1716
+ try {
1717
+ const target = sync.requireProDevice(args.deviceId);
1718
+ await sync.callAction(ROUTES_TOOL_ID, "navigate", { path: args.path }, target);
1719
+ return text8(`Navigated to \`${args.path}\`.`);
1720
+ } catch (e) {
1721
+ return fail9(errMsg9(e));
1722
+ }
1723
+ }
1724
+ );
1725
+ }
1726
+
1727
+ // src/tools/packs/console.ts
1728
+ import { z as z10 } from "zod";
1729
+ var CONSOLE_TOOL_ID = "console";
1730
+ var text9 = (s) => ({ content: [{ type: "text", text: s }] });
1731
+ var fail10 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1732
+ var errMsg10 = (e) => e instanceof Error ? e.message : String(e);
1733
+ var SEVERITY = { verbose: 0, info: 1, warning: 2, error: 3 };
1734
+ var TAG = { verbose: "\xB7", info: "info", warning: "warn", error: "ERR" };
1735
+ function hhmmss(ts) {
1736
+ const d = new Date(ts);
1737
+ const p = (n, l = 2) => String(n).padStart(l, "0");
1738
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
1739
+ }
1740
+ function registerConsoleTools(server, sync) {
1741
+ server.registerTool(
1742
+ "get_console",
1743
+ {
1744
+ description: "Read the app's captured console logs (console.log/warn/error/\u2026) as a compact tail \u2014 time \xB7 level \xB7 message. Token-cheap (uses each entry's pre-rendered message, not raw args). Filter by minimum level (e.g. level:'warning' for warnings+errors) or a message substring. Use for 'what's logging / any console errors'. For full args/stack of an entry, fall back to get_snapshot('console').",
1745
+ inputSchema: {
1746
+ level: z10.enum(["verbose", "info", "warning", "error"]).optional().describe("Minimum level to include (severity threshold). e.g. 'warning' = warnings + errors. Default all."),
1747
+ limit: z10.number().optional().describe("Most-recent N entries. Default 50."),
1748
+ pattern: z10.string().optional().describe("Only entries whose message contains this substring (case-insensitive)."),
1749
+ deviceId: z10.string().optional().describe("Target device id (auto-selected if one is connected).")
1750
+ }
1751
+ },
1752
+ async (args) => {
1753
+ try {
1754
+ const target = sync.requireProDevice(args.deviceId);
1755
+ const { payload } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
1756
+ const p = Array.isArray(payload) ? { entries: payload } : payload ?? {};
1757
+ const all = Array.isArray(p.entries) ? p.entries : [];
1758
+ const minSev = args.level ? SEVERITY[args.level] : 0;
1759
+ const pattern = args.pattern?.toLowerCase();
1760
+ const filtered = all.filter(
1761
+ (e) => SEVERITY[e.level] >= minSev && (!pattern || (e.message ?? "").toLowerCase().includes(pattern))
1762
+ );
1763
+ const sorted = [...filtered].sort((a, b) => a.id - b.id);
1764
+ const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 50;
1765
+ const shown = sorted.slice(-limit);
1766
+ if (shown.length === 0) {
1767
+ return text9(`No console entries${args.level ? ` at level \u2265 ${args.level}` : ""}${pattern ? ` matching "${args.pattern}"` : ""} (${all.length} total captured).`);
1768
+ }
1769
+ const lines = shown.map((e) => `${hhmmss(e.timestamp)} [${TAG[e.level] ?? e.level}] ${e.message}`);
1770
+ const levelNote = args.level ? `, level \u2265 ${args.level}` : "";
1771
+ const header = `**Console \u2014 ${all.length} entries (showing ${shown.length}${levelNote}):**`;
1772
+ return text9(`${header}
1773
+
1774
+ \`\`\`
1775
+ ${lines.join("\n")}
1776
+ \`\`\`
1777
+
1778
+ _Tail of recent logs. Use get_snapshot('console') for full args/stacks._`);
1779
+ } catch (e) {
1780
+ return fail10(errMsg10(e));
1781
+ }
1782
+ }
1783
+ );
1784
+ }
1785
+
1786
+ // src/tools/packs/screenshot.ts
1787
+ import { z as z11 } from "zod";
1788
+
1789
+ // src/capture/simctl.ts
1790
+ import { execFile } from "child_process";
1791
+ import { promisify } from "util";
1792
+ import { promises as fs2 } from "fs";
1793
+ import os2 from "os";
1794
+ import path2 from "path";
1795
+ import { randomUUID as randomUUID2 } from "crypto";
1796
+ var execFileAsync = promisify(execFile);
1797
+ var clamp2 = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
1798
+ function humanizeSimctlError(err) {
1799
+ const e = err;
1800
+ if (e?.code === "ENOENT") {
1801
+ return "Xcode command-line tools (xcrun) not found. Install Xcode to capture the iOS Simulator.";
1802
+ }
1803
+ const msg = String(e?.stderr || e?.message || err);
1804
+ if (/No devices are booted|Unable to find device|current device/i.test(msg)) {
1805
+ return "No booted iOS Simulator found. Boot the Simulator with your app running, then try again.";
1806
+ }
1807
+ return msg.trim() || "Failed to capture the iOS Simulator.";
1808
+ }
1809
+ function pngSize(buf) {
1810
+ const SIG = [137, 80, 78, 71, 13, 10, 26, 10];
1811
+ if (buf.length < 24 || !SIG.every((b, i) => buf[i] === b)) return null;
1812
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
1813
+ }
1814
+ async function captureIosFramebuffer() {
1815
+ if (process.platform !== "darwin") {
1816
+ return {
1817
+ ok: false,
1818
+ error: "Screenshotting a component requires macOS with Xcode's iOS Simulator (uses `xcrun simctl`)."
1819
+ };
1820
+ }
1821
+ const out = path2.join(os2.tmpdir(), `buoy-mcp-sim-${randomUUID2()}.png`);
1822
+ try {
1823
+ await execFileAsync("xcrun", ["simctl", "io", "booted", "screenshot", "--type=png", out], {
1824
+ timeout: 15e3
1825
+ });
1826
+ } catch (err) {
1827
+ return { ok: false, error: humanizeSimctlError(err) };
1828
+ }
1829
+ try {
1830
+ const buf = await fs2.readFile(out);
1831
+ const size = pngSize(buf);
1832
+ if (!size) return { ok: false, error: "Captured file wasn't a valid PNG." };
1833
+ return { ok: true, srcPath: out, width: size.width, height: size.height };
1834
+ } catch (err) {
1835
+ return { ok: false, error: `Failed to read screenshot: ${err.message}` };
1836
+ }
1837
+ }
1838
+ async function cropToBuffer(srcPath, full, region) {
1839
+ const x = clamp2(Math.round(region.x), 0, Math.max(0, full.width - 1));
1840
+ const y = clamp2(Math.round(region.y), 0, Math.max(0, full.height - 1));
1841
+ const width = clamp2(Math.round(region.width), 1, full.width - x);
1842
+ const height = clamp2(Math.round(region.height), 1, full.height - y);
1843
+ const out = path2.join(os2.tmpdir(), `buoy-mcp-crop-${randomUUID2()}.png`);
1844
+ try {
1845
+ await execFileAsync(
1846
+ "sips",
1847
+ ["-c", String(height), String(width), "--cropOffset", String(y), String(x), srcPath, "--out", out],
1848
+ { timeout: 1e4 }
1849
+ );
1850
+ const buf = await fs2.readFile(out);
1851
+ return { ok: true, buffer: buf, width, height };
1852
+ } catch (err) {
1853
+ const e = err;
1854
+ return { ok: false, error: `Crop failed: ${e?.stderr || e?.message || err}` };
1855
+ } finally {
1856
+ await fs2.unlink(out).catch(() => {
1857
+ });
1858
+ await fs2.unlink(srcPath).catch(() => {
1859
+ });
1860
+ }
1861
+ }
1862
+
1863
+ // src/tools/packs/screenshot.ts
1864
+ var TOOL_ID = "highlight-updates";
1865
+ var MARGIN_PT = 12;
1866
+ var CAPTURE_PADDING_PT = 6;
1867
+ var fail11 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1868
+ var errMsg11 = (e) => e instanceof Error ? e.message : String(e);
1869
+ function reasonMessage(res, query) {
1870
+ switch (res.reason) {
1871
+ case "no-renders":
1872
+ return "No components are tracked yet. Navigate to the screen with the target component in the running app (it needs to render at least once), then retry.";
1873
+ case "no-match":
1874
+ return query ? `Nothing matched "${query}".` : "No query or nativeTag given \u2014 pass one, or a testID/name to search for.";
1875
+ case "no-measurement":
1876
+ return "Found the component but couldn't measure it \u2014 it may have just unmounted. Re-navigate to it and retry.";
1877
+ default:
1878
+ return res.reason ? `Locate failed: ${res.reason}` : "Couldn't locate that component.";
1879
+ }
1880
+ }
1881
+ function candidatesText(candidates) {
1882
+ if (!candidates || candidates.length === 0) return "";
1883
+ const lines = candidates.map((c) => {
1884
+ const id = c.testID || c.nativeID;
1885
+ return `- ${c.componentName || c.viewType}${id ? ` (${id})` : ""} \u2014 nativeTag ${c.nativeTag}, rendered \xD7${c.renderCount}`;
1886
+ });
1887
+ return `
1888
+
1889
+ Closest matches (retry with a more precise query, or pass nativeTag directly):
1890
+ ${lines.join("\n")}`;
1891
+ }
1892
+ function registerScreenshotTools(server, sync) {
1893
+ server.registerTool(
1894
+ "screenshot_component",
1895
+ {
1896
+ description: "Take a tight, cropped screenshot of a single React Native component running in the iOS Simulator \u2014 no manual coordinates needed. Give a testID, nativeID, accessibilityLabel, or component name in `query` (or an exact `nativeTag` from a previous call's candidates) and it locates the live component on-device (scrolling it into view), then captures + crops the Simulator framebuffer to just that component. The component must have rendered at least once since the app opened for it to be trackable \u2014 if nothing matches, navigate to the screen that renders it and retry. iOS Simulator only, and the MCP server must be running on the same Mac as the booted Simulator (uses `xcrun simctl` + `sips`). Requires Buoy Pro.",
1897
+ inputSchema: {
1898
+ query: z11.string().optional().describe('Fuzzy match against testID, nativeID, accessibilityLabel, component name, or view type \u2014 e.g. "submit-button".'),
1899
+ nativeTag: z11.number().optional().describe("An exact nativeTag (from a previous call's candidates) to skip fuzzy matching."),
1900
+ scrollIntoView: z11.boolean().optional().describe("Scroll the component into view before capturing, if it's off-screen. Default true."),
1901
+ deviceId: z11.string().optional().describe("Target device id (auto-selected if one is connected).")
1902
+ }
1903
+ },
1904
+ async (args) => {
1905
+ try {
1906
+ const target = sync.requireProDevice(args.deviceId);
1907
+ await sync.callAction(TOOL_ID, "setSilentTracking", { enabled: true }, target).catch(() => {
1908
+ });
1909
+ const res = await sync.callAction(
1910
+ TOOL_ID,
1911
+ "locateComponent",
1912
+ {
1913
+ query: args.query,
1914
+ nativeTag: args.nativeTag,
1915
+ scrollIntoView: args.scrollIntoView ?? true,
1916
+ margin: MARGIN_PT
1917
+ },
1918
+ target
1919
+ );
1920
+ if (!res.matched || !res.rect || !res.screen) {
1921
+ return fail11(reasonMessage(res, args.query) + candidatesText(res.candidates));
1922
+ }
1923
+ const shot = await captureIosFramebuffer();
1924
+ if (!shot.ok || !shot.srcPath || !shot.width || !shot.height) {
1925
+ return fail11(shot.error ?? "Screenshot capture failed.");
1926
+ }
1927
+ const pxScale = shot.width / res.screen.width;
1928
+ const r = res.rect;
1929
+ const pad = CAPTURE_PADDING_PT;
1930
+ const crop = await cropToBuffer(
1931
+ shot.srcPath,
1932
+ { width: shot.width, height: shot.height },
1933
+ {
1934
+ x: (r.x - pad) * pxScale,
1935
+ y: (r.y - pad) * pxScale,
1936
+ width: (r.width + pad * 2) * pxScale,
1937
+ height: (r.height + pad * 2) * pxScale
1938
+ }
1939
+ );
1940
+ if (!crop.ok || !crop.buffer) {
1941
+ return fail11(crop.error ?? "Crop failed.");
1942
+ }
1943
+ const label = res.componentName || "component";
1944
+ const idPart = res.testID ? ` (testID: "${res.testID}")` : "";
1945
+ const warn = res.inView === false ? "\n\nNote: the component is partly off-screen even after scrolling, so the crop is clamped to the screen edge." : "";
1946
+ const summary = `Captured "${label}"${idPart} \u2014 ${crop.width}\xD7${crop.height}px.${warn}`;
1947
+ return {
1948
+ content: [
1949
+ { type: "image", data: crop.buffer.toString("base64"), mimeType: "image/png" },
1950
+ { type: "text", text: summary }
1951
+ ]
1952
+ };
1953
+ } catch (e) {
1954
+ return fail11(errMsg11(e));
1955
+ }
1956
+ }
1957
+ );
1958
+ }
1959
+
1960
+ // src/server.ts
1961
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1962
+ import { z as z13 } from "zod";
1963
+
1964
+ // src/tools/packs/interact.ts
1965
+ import { z as z12 } from "zod";
1966
+ var TOOL_ID2 = "highlight-updates";
1967
+ var text10 = (s) => ({ content: [{ type: "text", text: s }] });
1968
+ var fail12 = (message) => ({
1969
+ isError: true,
1970
+ content: [{ type: "text", text: message }]
1971
+ });
1972
+ var errMsg12 = (e) => e instanceof Error ? e.message : String(e);
1973
+ function formatElement(e) {
1974
+ let line = e.interactive ? "\u2022 " : " ";
1975
+ line += e.name;
1976
+ const display = e.text ?? e.label;
1977
+ if (display) line += ` "${display}"`;
1978
+ if (e.role) line += ` <${e.role}>`;
1979
+ if (e.control) line += ` {${e.control}${e.value !== void 0 ? `=${JSON.stringify(e.value)}` : ""}}`;
1980
+ if (e.testID) line += ` [testID=${e.testID}]`;
1981
+ if (e.tap) line += ` (tap: ${e.tap.x},${e.tap.y})`;
1982
+ if (e.nativeTag != null) line += ` #${e.nativeTag}`;
1983
+ return line;
1984
+ }
1985
+ function registerInteractTools(server, sync) {
1986
+ server.registerTool(
1987
+ "describe_screen",
1988
+ {
1989
+ description: "List the interactive / labeled elements currently on screen \u2014 the app's live accessibility-style tree walked from the React fiber tree. Each row shows the component name, visible text/label, role, testID, a normalized tap point (tap: x,y in 0\u20131), and its #nativeTag. Rows marked with \u2022 are tappable (have an onPress). Use this to see what's on screen and pick something to tap with tap_element \u2014 no screenshot needed. Re-run after navigating or when the screen changes, since positions move. Requires Buoy Pro.",
1990
+ inputSchema: {
1991
+ interactiveOnly: z12.boolean().optional().describe("Only return tappable (onPress) elements. Default false (include labels/text for context)."),
1992
+ deviceId: z12.string().optional().describe("Target device id (auto-selected if one is connected).")
1993
+ }
1994
+ },
1995
+ async (args) => {
1996
+ try {
1997
+ const target = sync.requireProDevice(args.deviceId);
1998
+ const res = await sync.callAction(TOOL_ID2, "describeScreen", {}, target);
1999
+ const all = res?.elements ?? [];
2000
+ const elements = args.interactiveOnly ? all.filter((e) => e.interactive) : all;
2001
+ if (elements.length === 0) {
2002
+ return text10(
2003
+ "No elements found on screen. If the app just navigated, wait a moment and retry; make sure highlight-updates is enabled in the app."
2004
+ );
2005
+ }
2006
+ const header = `Screen ${res.screen.width}\xD7${res.screen.height} \u2014 ${elements.length} element${elements.length === 1 ? "" : "s"} (\u2022 = tappable). Tap with tap_element({ testID | query | nativeTag }).`;
2007
+ return text10(`${header}
2008
+
2009
+ ${elements.map(formatElement).join("\n")}`);
2010
+ } catch (e) {
2011
+ return fail12(errMsg12(e));
2012
+ }
2013
+ }
2014
+ );
2015
+ server.registerTool(
2016
+ "tap_element",
2017
+ {
2018
+ description: "Interact with an on-screen element by invoking its handler directly in JS (no screenshot, no coordinate math, works on real devices). Handles buttons (onPress), toggles/switches and sliders (onValueChange), and text inputs (onChangeText) \u2014 see each element's `control` in describe_screen. Identify the target with an exact `nativeTag` or `testID`, or a fuzzy `query`. For a toggle, pass `value:true/false` (omit to flip); for a slider, pass a numeric `value` (omit to jump to the far end); for a text input, pass `text`. If the element is off-screen it's scrolled into view first (scrollIntoView:false to skip). Call describe_screen first to see options + current values. Requires Buoy Pro.",
2019
+ inputSchema: {
2020
+ nativeTag: z12.number().optional().describe("Exact nativeTag from describe_screen (most precise)."),
2021
+ testID: z12.string().optional().describe("Exact testID of the element."),
2022
+ query: z12.string().optional().describe('Fuzzy match against testID / label / visible text / component name \u2014 e.g. "Sign in".'),
2023
+ value: z12.union([z12.number(), z12.boolean()]).optional().describe("For a toggle: true/false (omit to flip). For a slider: the numeric value (omit to jump to the far end)."),
2024
+ text: z12.string().optional().describe("For a text input: the string to set via onChangeText."),
2025
+ scrollIntoView: z12.boolean().optional().describe("Scroll the target into view first if it's off-screen. Default true."),
2026
+ deviceId: z12.string().optional().describe("Target device id (auto-selected if one is connected).")
2027
+ }
2028
+ },
2029
+ async (args) => {
2030
+ try {
2031
+ if (args.nativeTag == null && !args.testID && !args.query) {
2032
+ return fail12("Provide one of: nativeTag, testID, or query (from describe_screen).");
2033
+ }
2034
+ const target = sync.requireProDevice(args.deviceId);
2035
+ const res = await sync.callAction(
2036
+ TOOL_ID2,
2037
+ "tapElement",
2038
+ {
2039
+ nativeTag: args.nativeTag,
2040
+ testID: args.testID,
2041
+ query: args.query,
2042
+ value: args.value,
2043
+ text: args.text,
2044
+ scrollIntoView: args.scrollIntoView
2045
+ },
2046
+ target
2047
+ );
2048
+ if (res?.tapped && res.matched) {
2049
+ const m = res.matched;
2050
+ const label = m.text ?? m.label ?? m.name;
2051
+ const id = m.testID ? ` [testID=${m.testID}]` : m.nativeTag != null ? ` #${m.nativeTag}` : "";
2052
+ const scrolledNote = res.scrolled ? " (scrolled into view)" : "";
2053
+ const verb = m.via === "onValueChange" ? `Set "${label}"${id} \u2192 ${JSON.stringify(m.value)}` : m.via === "onChangeText" ? `Typed ${JSON.stringify(m.value)} into "${label}"${id}` : `Tapped "${label}"${id}${m.via === "onLongPress" ? " (via onLongPress)" : ""}`;
2054
+ return text10(`${verb}${scrolledNote}.`);
2055
+ }
2056
+ let msg = res?.reason ?? "Tap failed.";
2057
+ if (res?.candidates?.length) {
2058
+ msg += "\n\nTappable candidates:\n" + res.candidates.map((c) => `- ${c.name}${c.text ? ` "${c.text}"` : ""}${c.testID ? ` [testID=${c.testID}]` : ""}${c.nativeTag != null ? ` #${c.nativeTag}` : ""}`).join("\n");
2059
+ }
2060
+ return fail12(msg);
2061
+ } catch (e) {
2062
+ return fail12(errMsg12(e));
2063
+ }
2064
+ }
2065
+ );
2066
+ }
2067
+
2068
+ // src/server.ts
2069
+ var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
2070
+
2071
+ Buoy tools live inside the app and sync to a local broker; this server connects to that
2072
+ broker as a dashboard client. Start by calling \`list_devices\` to see which device(s) are
2073
+ connected and which tools (e.g. "perf-monitor", "network", "react-query") they expose.
2074
+
2075
+ To inspect what's HAPPENING at runtime \u2014 network requests, state changes (redux/zustand/jotai),
2076
+ react-query updates, storage writes, route changes, renders \u2014 use \`get_events\` with a \`sources\`
2077
+ filter (e.g. sources:["network"] to list HTTP requests). It's the universal, token-bounded
2078
+ activity reader across ALL tools and is compact by default \u2014 reach for it before reading source
2079
+ code, and prefer it over \`get_snapshot\` for any "what happened / show me the requests" question.
2080
+
2081
+ Need to SEE a component (verify a UI change, check styling/layout)? Use \`screenshot_component\` with
2082
+ a testID/name \u2014 it locates the live component on-device and returns a cropped screenshot inline.
2083
+ iOS Simulator only, and only works when this MCP server runs on the same Mac as the booted Simulator.
2084
+
2085
+ Need to DRIVE the UI (tap a button, follow a flow)? Use \`describe_screen\` to list the on-screen
2086
+ elements (accessibility-style tree with tap points + testIDs, no screenshot), then \`tap_element\`
2087
+ ({ testID | query | nativeTag }) to tap one \u2014 it invokes the element's onPress directly in JS, so
2088
+ it's fast and works on physical devices too. Re-run \`describe_screen\` after the screen changes.
2089
+
2090
+ For everything else:
2091
+ - \`list_tool_actions\` to see what a tool can do,
2092
+ - \`get_snapshot\` ONLY when you need a tool's full current state (returns raw, heavy payloads),
2093
+ - \`call_action\` to invoke any tool action generically.
2094
+
2095
+ Higher-level tools (e.g. perf-monitor's \`run_benchmark_batch\`) wrap these for common workflows.
2096
+ If no device is connected, open the app with Buoy devtools running and try again.
2097
+
2098
+ The Buoy MCP is a Pro feature: \`list_devices\` is always available, but data/action tools
2099
+ require the connected app to have an active Buoy Pro license. If they error with "Buoy Pro
2100
+ required", tell the user to upgrade to Buoy Pro in the app \u2014 don't try to work around it.`;
2101
+ function createMcpServer(sync) {
2102
+ const server = new McpServer(
2103
+ { name: "buoy", version: VERSION },
2104
+ { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
2105
+ );
2106
+ registerGenericTools(server, sync);
2107
+ registerPerfMonitorTools(server, sync);
2108
+ registerEventsTools(server, sync);
2109
+ registerReactQueryTools(server, sync);
2110
+ registerZustandTools(server, sync);
2111
+ registerJotaiTools(server, sync);
2112
+ registerReduxTools(server, sync);
2113
+ registerStorageTools(server, sync);
2114
+ registerRoutesTools(server, sync);
2115
+ registerConsoleTools(server, sync);
2116
+ registerScreenshotTools(server, sync);
2117
+ registerInteractTools(server, sync);
2118
+ return server;
2119
+ }
2120
+ var deviceIdField = {
2121
+ deviceId: z13.string().optional().describe(
2122
+ 'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
2123
+ )
2124
+ };
2125
+
2126
+ // src/init.ts
2127
+ import fs3 from "fs";
2128
+ import path3 from "path";
2129
+ function runInit(opts = {}) {
2130
+ const cwd = opts.cwd ?? process.cwd();
2131
+ const skillSrcDir = opts.skillSrcDir ?? path3.resolve(__dirname, "../skill");
2132
+ const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
2133
+ const entry = opts.cliPath ? { type: "stdio", command: "node", args: [opts.cliPath] } : { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
2134
+ const done = [];
2135
+ upsertMcpConfig(path3.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
2136
+ if (fs3.existsSync(path3.join(cwd, ".cursor"))) {
2137
+ upsertMcpConfig(path3.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
2138
+ }
2139
+ if (fs3.existsSync(path3.join(cwd, ".vscode"))) {
2140
+ upsertMcpConfig(path3.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
2141
+ }
2142
+ installSkills(skillSrcDir, cwd, done);
2143
+ log("Buoy MCP installed:");
2144
+ for (const line of done) log(" \u2022 " + line);
2145
+ log("");
2146
+ log("Next steps:");
2147
+ log(" 1. Restart your editor (or reconnect the MCP server) to load `buoy`.");
2148
+ log(" 2. Open your app with Buoy devtools running on a device or simulator.");
2149
+ log(' 3. Say "buoy optimize" to start a guided performance pass.');
2150
+ if (!opts.cliPath) {
2151
+ log("");
2152
+ log(`This launches the server via \`npx -y ${PACKAGE_NAME}@latest\`, so it stays`);
2153
+ log(`up to date automatically. To force-refresh, re-run \`npx -y ${PACKAGE_NAME}@latest init\`.`);
2154
+ }
2155
+ }
2156
+ function upsertMcpConfig(file, key, entry, cwd, done) {
2157
+ let json3 = {};
2158
+ if (fs3.existsSync(file)) {
2159
+ try {
2160
+ json3 = JSON.parse(fs3.readFileSync(file, "utf8"));
2161
+ } catch {
2162
+ fs3.copyFileSync(file, file + ".bak");
2163
+ done.push(`existing ${rel(file, cwd)} was invalid JSON \u2014 backed up to ${rel(file, cwd)}.bak`);
2164
+ json3 = {};
2165
+ }
2166
+ } else {
2167
+ fs3.mkdirSync(path3.dirname(file), { recursive: true });
2168
+ }
2169
+ const servers = json3[key] && typeof json3[key] === "object" ? json3[key] : {};
2170
+ const existed = "buoy" in servers;
2171
+ servers.buoy = entry;
2172
+ json3[key] = servers;
2173
+ fs3.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
2174
+ done.push(`${existed ? "updated" : "added"} \`buoy\` in ${rel(file, cwd)}`);
2175
+ }
2176
+ function installSkills(skillSrcDir, cwd, done) {
2177
+ if (!fs3.existsSync(skillSrcDir)) {
2178
+ done.push("\u26A0 no bundled skills found \u2014 skipped");
2179
+ return;
2180
+ }
2181
+ const skills = fs3.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && fs3.existsSync(path3.join(skillSrcDir, e.name, "SKILL.md")));
2182
+ if (skills.length === 0) {
2183
+ done.push("\u26A0 no skills with a SKILL.md found \u2014 skipped");
2184
+ return;
2185
+ }
2186
+ for (const skill of skills) {
2187
+ const from = path3.join(skillSrcDir, skill.name);
2188
+ const to = path3.join(cwd, ".claude", "skills", skill.name);
2189
+ copyDir(from, to);
2190
+ done.push(`installed the ${skill.name} skill \u2192 ${rel(path3.join(to, "SKILL.md"), cwd)}`);
2191
+ }
2192
+ }
2193
+ function copyDir(from, to) {
2194
+ fs3.mkdirSync(to, { recursive: true });
2195
+ for (const entry of fs3.readdirSync(from, { withFileTypes: true })) {
2196
+ const s = path3.join(from, entry.name);
2197
+ const d = path3.join(to, entry.name);
2198
+ if (entry.isDirectory()) copyDir(s, d);
2199
+ else fs3.copyFileSync(s, d);
2200
+ }
2201
+ }
2202
+ function rel(file, cwd) {
2203
+ const r = path3.relative(cwd, file);
2204
+ return r && !r.startsWith("..") ? r : file;
2205
+ }
2206
+
2207
+ export {
2208
+ BROKER_PORT,
2209
+ BROKER_URL,
2210
+ DEFAULT_ACTION_TIMEOUT_MS,
2211
+ DEFAULT_SNAPSHOT_TIMEOUT_MS,
2212
+ DASHBOARD_NAME,
2213
+ ALL_DEVICES,
2214
+ SyncClient,
2215
+ VERSION,
2216
+ PACKAGE_NAME,
2217
+ getUpdateNotice,
2218
+ registerGenericTools,
2219
+ buildAutomationConfig,
2220
+ estimateBatchMs,
2221
+ rankBatchFromIndex,
2222
+ formatRankingMarkdown,
2223
+ compareReportStats,
2224
+ registerPerfMonitorTools,
2225
+ registerEventsTools,
2226
+ registerReactQueryTools,
2227
+ registerZustandTools,
2228
+ registerJotaiTools,
2229
+ registerReduxTools,
2230
+ registerStorageTools,
2231
+ registerRoutesTools,
2232
+ registerConsoleTools,
2233
+ registerScreenshotTools,
2234
+ createMcpServer,
2235
+ runInit
2236
+ };