@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.
- package/dist/chunk-SQKZOREF.mjs +2236 -0
- package/dist/cli.js +1050 -33
- package/dist/cli.mjs +3 -67
- package/dist/index.d.mts +113 -1
- package/dist/index.d.ts +113 -1
- package/dist/index.js +1115 -9
- package/dist/index.mjs +29 -3
- package/package.json +2 -2
- package/dist/chunk-55UIOHVS.mjs +0 -1145
package/dist/chunk-55UIOHVS.mjs
DELETED
|
@@ -1,1145 +0,0 @@
|
|
|
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/tools/generic.ts
|
|
299
|
-
import { z } from "zod";
|
|
300
|
-
function json(value) {
|
|
301
|
-
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
302
|
-
}
|
|
303
|
-
function fail(message) {
|
|
304
|
-
return { isError: true, content: [{ type: "text", text: message }] };
|
|
305
|
-
}
|
|
306
|
-
function registerGenericTools(server, sync) {
|
|
307
|
-
server.registerTool(
|
|
308
|
-
"list_devices",
|
|
309
|
-
{
|
|
310
|
-
description: "List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",
|
|
311
|
-
inputSchema: {}
|
|
312
|
-
},
|
|
313
|
-
async () => {
|
|
314
|
-
const devices = sync.listDevices();
|
|
315
|
-
if (devices.length === 0) {
|
|
316
|
-
return json({
|
|
317
|
-
devices: [],
|
|
318
|
-
hint: "No devices connected. Open the app with Buoy devtools running, then retry."
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
const anyPro = devices.some((d) => d.isConnected && d.isPro);
|
|
322
|
-
return json({
|
|
323
|
-
devices,
|
|
324
|
-
...anyPro ? {} : {
|
|
325
|
-
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."
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
);
|
|
330
|
-
server.registerTool(
|
|
331
|
-
"list_tool_actions",
|
|
332
|
-
{
|
|
333
|
-
description: "List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",
|
|
334
|
-
inputSchema: {
|
|
335
|
-
toolId: z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')
|
|
336
|
-
}
|
|
337
|
-
},
|
|
338
|
-
async ({ toolId }) => {
|
|
339
|
-
const offerings = sync.toolActions(toolId);
|
|
340
|
-
if (offerings.length === 0) {
|
|
341
|
-
return fail(
|
|
342
|
-
`No connected device exposes tool "${toolId}". Call list_devices to see available tools.`
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
return json({ toolId, offerings });
|
|
346
|
-
}
|
|
347
|
-
);
|
|
348
|
-
server.registerTool(
|
|
349
|
-
"get_snapshot",
|
|
350
|
-
{
|
|
351
|
-
description: "Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",
|
|
352
|
-
inputSchema: {
|
|
353
|
-
toolId: z.string().describe("The tool id to read state from."),
|
|
354
|
-
deviceId: z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
|
|
355
|
-
timeoutMs: z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")
|
|
356
|
-
}
|
|
357
|
-
},
|
|
358
|
-
async ({ toolId, deviceId, timeoutMs }) => {
|
|
359
|
-
try {
|
|
360
|
-
const target = sync.requireProDevice(deviceId);
|
|
361
|
-
const { payload } = await sync.getSnapshot(toolId, target, { timeoutMs });
|
|
362
|
-
return json({ toolId, deviceId: target, snapshot: payload });
|
|
363
|
-
} catch (e) {
|
|
364
|
-
return fail(errMsg(e));
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
);
|
|
368
|
-
server.registerTool(
|
|
369
|
-
"call_action",
|
|
370
|
-
{
|
|
371
|
-
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.",
|
|
372
|
-
inputSchema: {
|
|
373
|
-
toolId: z.string().describe('The tool id, e.g. "perf-monitor".'),
|
|
374
|
-
action: z.string().describe('The action name, e.g. "startRecording", "clearAll".'),
|
|
375
|
-
params: z.record(z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),
|
|
376
|
-
deviceId: z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),
|
|
377
|
-
timeoutMs: z.number().optional().describe("Max time to wait for the device's result.")
|
|
378
|
-
}
|
|
379
|
-
},
|
|
380
|
-
async ({ toolId, action, params, deviceId, timeoutMs }) => {
|
|
381
|
-
try {
|
|
382
|
-
const target = sync.requireProDevice(deviceId);
|
|
383
|
-
const result = await sync.callAction(toolId, action, params, target, { timeoutMs });
|
|
384
|
-
return json({ toolId, action, ok: true, result: result ?? null });
|
|
385
|
-
} catch (e) {
|
|
386
|
-
return fail(errMsg(e));
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
function errMsg(e) {
|
|
392
|
-
return e instanceof Error ? e.message : String(e);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// src/tools/packs/perfRanking.ts
|
|
396
|
-
var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
397
|
-
var FALLBACK = {
|
|
398
|
-
perCaseDurationMs: 5e3,
|
|
399
|
-
settleMs: 600,
|
|
400
|
-
navTimeoutMs: 5e3,
|
|
401
|
-
runsPerCase: 3,
|
|
402
|
-
coolDownMs: 2e3,
|
|
403
|
-
discardWarmupRuns: 0
|
|
404
|
-
};
|
|
405
|
-
function pick(override, saved, fallback) {
|
|
406
|
-
return override ?? saved ?? fallback;
|
|
407
|
-
}
|
|
408
|
-
function buildAutomationConfig(input, base = {}) {
|
|
409
|
-
const warnings = [];
|
|
410
|
-
const targetRoute = (input.targetRoute || "").trim();
|
|
411
|
-
if (!targetRoute) throw new Error("targetRoute is required (the screen to benchmark).");
|
|
412
|
-
let bounceRoute = (input.bounceRoute || "").trim();
|
|
413
|
-
if (!bounceRoute) bounceRoute = targetRoute === "/" ? "/_buoy_bounce" : "/";
|
|
414
|
-
if (bounceRoute === targetRoute) {
|
|
415
|
-
throw new Error(
|
|
416
|
-
`bounceRoute ("${bounceRoute}") must differ from targetRoute \u2014 it's the screen we bounce to so the test screen fully remounts each run.`
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
if (!Array.isArray(input.cases) || input.cases.length === 0) {
|
|
420
|
-
throw new Error("At least one case is required.");
|
|
421
|
-
}
|
|
422
|
-
const cases = input.cases.map((c, i) => {
|
|
423
|
-
const name = (c.name || "").trim() || `case ${i + 1}`;
|
|
424
|
-
return { id: `case-${i}`, name, params: c.params ?? {}, route: c.route };
|
|
425
|
-
});
|
|
426
|
-
const runsPerCase = clamp(Math.round(pick(input.runsPerCase, base.runsPerCase, FALLBACK.runsPerCase)), 1, 10);
|
|
427
|
-
const coolDownMs = clamp(Math.round(pick(input.coolDownMs, base.coolDownMs, FALLBACK.coolDownMs)), 0, 3e4);
|
|
428
|
-
const discardWarmupRuns = clamp(
|
|
429
|
-
Math.round(pick(input.discardWarmupRuns, base.discardWarmupRuns, FALLBACK.discardWarmupRuns)),
|
|
430
|
-
0,
|
|
431
|
-
runsPerCase - 1
|
|
432
|
-
);
|
|
433
|
-
const shuffleCases = input.shuffleCases ?? base.shuffleCases ?? cases.length > 3;
|
|
434
|
-
if (cases.length > 3 && shuffleCases === false) {
|
|
435
|
-
warnings.push(
|
|
436
|
-
"shuffleCases is off with >3 cases \u2014 later cases may be thermally throttled, biasing results. Consider enabling it."
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
if (runsPerCase === 1) {
|
|
440
|
-
warnings.push("runsPerCase=1 \u2014 no median/variance; a single noisy run can mislead. 3+ is recommended.");
|
|
441
|
-
}
|
|
442
|
-
const config = {
|
|
443
|
-
targetRoute,
|
|
444
|
-
bounceRoute,
|
|
445
|
-
perCaseDurationMs: clamp(
|
|
446
|
-
Math.round(pick(input.perCaseDurationMs, base.perCaseDurationMs, FALLBACK.perCaseDurationMs)),
|
|
447
|
-
1e3,
|
|
448
|
-
6e4
|
|
449
|
-
),
|
|
450
|
-
settleMs: clamp(Math.round(pick(input.settleMs, base.settleMs, FALLBACK.settleMs)), 0, 1e4),
|
|
451
|
-
navTimeoutMs: clamp(Math.round(pick(input.navTimeoutMs, base.navTimeoutMs, FALLBACK.navTimeoutMs)), 1e3, 3e4),
|
|
452
|
-
cases,
|
|
453
|
-
runsPerCase,
|
|
454
|
-
coolDownMs,
|
|
455
|
-
discardWarmupRuns,
|
|
456
|
-
shuffleCases,
|
|
457
|
-
reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false
|
|
458
|
-
};
|
|
459
|
-
return { config, warnings };
|
|
460
|
-
}
|
|
461
|
-
function describeBenchmarkSettings(config) {
|
|
462
|
-
const secs = (ms) => ms >= 1e3 ? `${(ms / 1e3).toFixed(ms % 1e3 ? 1 : 0)}s` : `${ms}ms`;
|
|
463
|
-
const runs = config.runsPerCase ?? 1;
|
|
464
|
-
const estMs = estimateBatchMs(config);
|
|
465
|
-
const estMin = estMs / 6e4;
|
|
466
|
-
const est = estMin >= 1 ? `~${estMin.toFixed(1)} min` : `~${Math.round(estMs / 1e3)}s`;
|
|
467
|
-
const parts = [
|
|
468
|
-
`**${secs(config.perCaseDurationMs)} measured per page**`,
|
|
469
|
-
runs > 1 ? `**${runs} runs each** (median taken)` : `**1 run each** (no median \u2014 noisier)`,
|
|
470
|
-
config.reloadBetweenCases ? "**full reload between pages** (no state leaks across tests)" : "no reload between pages",
|
|
471
|
-
config.shuffleCases ? "shuffled order (cancels thermal bias)" : "fixed order"
|
|
472
|
-
];
|
|
473
|
-
const perPageMs = config.settleMs + config.perCaseDurationMs + (config.coolDownMs ?? 0) + 1800;
|
|
474
|
-
return `${parts.join(", ")}. That's ~${secs(perPageMs)} wall-clock per page; for ${config.cases.length} page(s) \xD7 ${runs} run(s), **${est}** total.`;
|
|
475
|
-
}
|
|
476
|
-
function estimateBatchMs(config) {
|
|
477
|
-
const NAV_OVERHEAD = 1800;
|
|
478
|
-
const perRun = config.settleMs + config.perCaseDurationMs + config.coolDownMs + NAV_OVERHEAD;
|
|
479
|
-
let total = config.cases.length * (config.runsPerCase ?? 1) * perRun;
|
|
480
|
-
if (config.reloadBetweenCases) total += config.cases.length * 3e3;
|
|
481
|
-
return total;
|
|
482
|
-
}
|
|
483
|
-
function computeFlags(r) {
|
|
484
|
-
const flags = [];
|
|
485
|
-
const max = r.deviceMaxRefreshRate;
|
|
486
|
-
if (r.uiFps != null && max && r.uiFps < max * 0.95) {
|
|
487
|
-
flags.push("UI thread drops frames");
|
|
488
|
-
} else if (r.uiJank != null && r.uiJank >= 2) {
|
|
489
|
-
flags.push("UI jank spikes");
|
|
490
|
-
}
|
|
491
|
-
if (r.cpu != null && r.cpu >= 90) flags.push("CPU near cap");
|
|
492
|
-
if (r.jsJank != null && r.jsJank >= 5) flags.push("JS thread stalls");
|
|
493
|
-
return flags;
|
|
494
|
-
}
|
|
495
|
-
function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
|
|
496
|
-
const entries = index.filter((e) => e.batchId === batchId);
|
|
497
|
-
const failureByIndex = new Map(failures.map((f) => [f.index, f.reason]));
|
|
498
|
-
const indexes = casesOrder ? casesOrder.map((_, i) => i) : Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort((a, b) => a - b);
|
|
499
|
-
const medianForCase = (batchIndex) => {
|
|
500
|
-
const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex);
|
|
501
|
-
if (runs.length === 0) return void 0;
|
|
502
|
-
return runs.find((e) => e.isMedianRun === true) ?? runs.find((e) => e.runIndex === 0 || e.runIndex === void 0) ?? runs[0];
|
|
503
|
-
};
|
|
504
|
-
const num = (v) => typeof v === "number" ? v : null;
|
|
505
|
-
const rows = indexes.map((batchIndex) => {
|
|
506
|
-
const median = medianForCase(batchIndex);
|
|
507
|
-
const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex).length;
|
|
508
|
-
const caseName = casesOrder?.[batchIndex]?.name ?? median?.name ?? `case ${batchIndex + 1}`;
|
|
509
|
-
const failureReason = failureByIndex.get(batchIndex) ?? median?.batchFailureReason;
|
|
510
|
-
const failed = Boolean(failureReason) || !median;
|
|
511
|
-
const base = {
|
|
512
|
-
batchIndex,
|
|
513
|
-
caseName,
|
|
514
|
-
params: median?.params ?? {},
|
|
515
|
-
runs,
|
|
516
|
-
failed,
|
|
517
|
-
failureReason: failed ? failureReason ?? "no run recorded" : void 0,
|
|
518
|
-
uiFps: num(median?.uiFpsAvg),
|
|
519
|
-
jsFps: num(median?.jsFpsAvg),
|
|
520
|
-
cpu: num(median?.cpuAvg),
|
|
521
|
-
memMb: num(median?.memMaxMb),
|
|
522
|
-
jsJank: num(median?.jsJankFrames),
|
|
523
|
-
uiJank: num(median?.uiJankFrames),
|
|
524
|
-
deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
|
|
525
|
-
deltaUiFps: null,
|
|
526
|
-
deltaJsFps: null
|
|
527
|
-
};
|
|
528
|
-
return { ...base, flags: failed ? [] : computeFlags(base) };
|
|
529
|
-
});
|
|
530
|
-
const baseline = rows[0];
|
|
531
|
-
if (baseline) {
|
|
532
|
-
for (const r of rows) {
|
|
533
|
-
if (r === baseline) continue;
|
|
534
|
-
if (r.uiFps != null && baseline.uiFps != null) r.deltaUiFps = round(r.uiFps - baseline.uiFps, 1);
|
|
535
|
-
if (r.jsFps != null && baseline.jsFps != null) r.deltaJsFps = round(r.jsFps - baseline.jsFps, 1);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
const live = rows.filter((r) => !r.failed);
|
|
539
|
-
const leaderBy = (sel, better) => {
|
|
540
|
-
let best;
|
|
541
|
-
let bestV = NaN;
|
|
542
|
-
for (const r of live) {
|
|
543
|
-
const v = sel(r);
|
|
544
|
-
if (v == null) continue;
|
|
545
|
-
if (best === void 0 || better(v, bestV)) {
|
|
546
|
-
best = r;
|
|
547
|
-
bestV = v;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
return best?.caseName;
|
|
551
|
-
};
|
|
552
|
-
const totalJank = (r) => r.jsJank == null && r.uiJank == null ? null : (r.jsJank ?? 0) + (r.uiJank ?? 0);
|
|
553
|
-
const leaders = {
|
|
554
|
-
uiFps: leaderBy((r) => r.uiFps, (a, b) => a > b),
|
|
555
|
-
jsFps: leaderBy((r) => r.jsFps, (a, b) => a > b),
|
|
556
|
-
lowestCpu: leaderBy((r) => r.cpu, (a, b) => a < b),
|
|
557
|
-
lowestMem: leaderBy((r) => r.memMb, (a, b) => a < b),
|
|
558
|
-
fewestJank: leaderBy(totalJank, (a, b) => a < b)
|
|
559
|
-
};
|
|
560
|
-
const flagged = live.filter((r) => r.flags.length > 0).map((r) => ({ caseName: r.caseName, flags: r.flags }));
|
|
561
|
-
const balanced = live.filter((r) => r.flags.length === 0).sort(
|
|
562
|
-
(a, b) => (b.uiFps ?? 0) - (a.uiFps ?? 0) || (a.cpu ?? Infinity) - (b.cpu ?? Infinity) || (a.memMb ?? Infinity) - (b.memMb ?? Infinity)
|
|
563
|
-
).map((r) => r.caseName);
|
|
564
|
-
return {
|
|
565
|
-
batchId,
|
|
566
|
-
baselineName: baseline?.caseName ?? null,
|
|
567
|
-
deviceMaxRefreshRate: live.find((r) => r.deviceMaxRefreshRate != null)?.deviceMaxRefreshRate ?? null,
|
|
568
|
-
rows,
|
|
569
|
-
leaders,
|
|
570
|
-
flagged,
|
|
571
|
-
balanced
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
function formatRankingMarkdown(ranking) {
|
|
575
|
-
const f = (v, d = 1) => v == null ? "\u2014" : v.toFixed(d);
|
|
576
|
-
const delta = (v) => v == null ? "" : v > 0 ? ` (+${v.toFixed(1)})` : ` (${v.toFixed(1)})`;
|
|
577
|
-
const lines = [];
|
|
578
|
-
lines.push(`### Benchmark batch \`${ranking.batchId}\``);
|
|
579
|
-
lines.push(
|
|
580
|
-
`**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.`
|
|
581
|
-
);
|
|
582
|
-
lines.push("");
|
|
583
|
-
lines.push("| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank | Flags |");
|
|
584
|
-
lines.push("|---|------|-------:|-------:|------:|-------:|--------:|--------:|-------|");
|
|
585
|
-
for (const r of ranking.rows) {
|
|
586
|
-
if (r.failed) {
|
|
587
|
-
lines.push(`| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${r.failureReason}) |`);
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
const flagCell = r.flags.length ? `\u26A0\uFE0F ${r.flags.join("; ")}` : "\u2713 clean";
|
|
591
|
-
lines.push(
|
|
592
|
-
`| ${r.batchIndex} | ${r.caseName} | ${f(r.uiFps)}${delta(r.deltaUiFps)} | ${f(r.jsFps)}${delta(
|
|
593
|
-
r.deltaJsFps
|
|
594
|
-
)} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} | ${flagCell} |`
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
lines.push("");
|
|
598
|
-
const L = ranking.leaders;
|
|
599
|
-
const leaderLine = [
|
|
600
|
-
L.uiFps && `UI FPS \u2192 **${L.uiFps}**`,
|
|
601
|
-
L.jsFps && `JS FPS \u2192 **${L.jsFps}**`,
|
|
602
|
-
L.lowestCpu && `lowest CPU \u2192 **${L.lowestCpu}**`,
|
|
603
|
-
L.lowestMem && `lowest mem \u2192 **${L.lowestMem}**`,
|
|
604
|
-
L.fewestJank && `fewest jank \u2192 **${L.fewestJank}**`
|
|
605
|
-
].filter(Boolean).join(" \xB7 ");
|
|
606
|
-
if (leaderLine) lines.push(`**Per-metric best:** ${leaderLine}`);
|
|
607
|
-
lines.push(
|
|
608
|
-
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.`
|
|
609
|
-
);
|
|
610
|
-
if (ranking.flagged.length) {
|
|
611
|
-
lines.push(
|
|
612
|
-
`**\u26A0\uFE0F At-risk:** ${ranking.flagged.map((x) => `${x.caseName} (${x.flags.join("; ")})`).join(" \xB7 ")}`
|
|
613
|
-
);
|
|
614
|
-
}
|
|
615
|
-
lines.push("");
|
|
616
|
-
lines.push(
|
|
617
|
-
`_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")._`
|
|
618
|
-
);
|
|
619
|
-
return lines.join("\n");
|
|
620
|
-
}
|
|
621
|
-
function compareReportStats(a, b) {
|
|
622
|
-
const ch = (x, y) => ({
|
|
623
|
-
a: { avg: round(x.avg, 1), p95: round(x.p95, 1) },
|
|
624
|
-
b: { avg: round(y.avg, 1), p95: round(y.p95, 1) },
|
|
625
|
-
deltaAvg: round(y.avg - x.avg, 1)
|
|
626
|
-
});
|
|
627
|
-
return {
|
|
628
|
-
a: a.name,
|
|
629
|
-
b: b.name,
|
|
630
|
-
uiFps: ch(a.stats.uiFps, b.stats.uiFps),
|
|
631
|
-
jsFps: ch(a.stats.jsFps, b.stats.jsFps),
|
|
632
|
-
cpuUsage: ch(a.stats.cpuUsage, b.stats.cpuUsage),
|
|
633
|
-
memoryUsage: ch(a.stats.memoryUsage, b.stats.memoryUsage),
|
|
634
|
-
jsJankFrames: { a: a.stats.jsJankFrames, b: b.stats.jsJankFrames, delta: b.stats.jsJankFrames - a.stats.jsJankFrames },
|
|
635
|
-
uiJankFrames: { a: a.stats.uiJankFrames, b: b.stats.uiJankFrames, delta: b.stats.uiJankFrames - a.stats.uiJankFrames }
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
function round(n, d) {
|
|
639
|
-
const p = Math.pow(10, d);
|
|
640
|
-
return Math.round(n * p) / p;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// src/tools/packs/perfMonitor.ts
|
|
644
|
-
import { z as z2 } from "zod";
|
|
645
|
-
|
|
646
|
-
// src/tools/packs/perfMonitorTypes.ts
|
|
647
|
-
var PERF_TOOL_ID = "perf-monitor";
|
|
648
|
-
|
|
649
|
-
// src/tools/packs/perfMonitor.ts
|
|
650
|
-
var POLL_INTERVAL_MS = 1500;
|
|
651
|
-
var delay2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
652
|
-
var json2 = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
653
|
-
var text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
654
|
-
var fail2 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
|
|
655
|
-
var errMsg2 = (e) => e instanceof Error ? e.message : String(e);
|
|
656
|
-
var caseSchema = z2.object({
|
|
657
|
-
name: z2.string().describe("Display name for this variant (becomes the saved run's name)."),
|
|
658
|
-
params: z2.record(z2.string()).optional().describe('Query params applied to the route for this case, e.g. { "renderer": "v2", "count": "100" }.'),
|
|
659
|
-
route: z2.string().optional().describe("Optional per-case route override; defaults to targetRoute.")
|
|
660
|
-
});
|
|
661
|
-
async function readDeviceSettings(sync, deviceId) {
|
|
662
|
-
try {
|
|
663
|
-
const cfg = await sync.callAction(PERF_TOOL_ID, "getAutomationConfig", {}, deviceId);
|
|
664
|
-
if (!cfg) return {};
|
|
665
|
-
const {
|
|
666
|
-
perCaseDurationMs,
|
|
667
|
-
settleMs,
|
|
668
|
-
navTimeoutMs,
|
|
669
|
-
runsPerCase,
|
|
670
|
-
coolDownMs,
|
|
671
|
-
discardWarmupRuns,
|
|
672
|
-
shuffleCases,
|
|
673
|
-
reloadBetweenCases
|
|
674
|
-
} = cfg;
|
|
675
|
-
return {
|
|
676
|
-
perCaseDurationMs,
|
|
677
|
-
settleMs,
|
|
678
|
-
navTimeoutMs,
|
|
679
|
-
runsPerCase,
|
|
680
|
-
coolDownMs,
|
|
681
|
-
discardWarmupRuns,
|
|
682
|
-
shuffleCases,
|
|
683
|
-
reloadBetweenCases
|
|
684
|
-
};
|
|
685
|
-
} catch {
|
|
686
|
-
return {};
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
async function readPerfSnapshot(sync, deviceId) {
|
|
690
|
-
const { payload } = await sync.getSnapshot(PERF_TOOL_ID, deviceId, { timeoutMs: 4e3 });
|
|
691
|
-
return payload;
|
|
692
|
-
}
|
|
693
|
-
function batchIdsIn(index) {
|
|
694
|
-
const ids = /* @__PURE__ */ new Set();
|
|
695
|
-
for (const e of index ?? []) if (e.batchId) ids.add(e.batchId);
|
|
696
|
-
return ids;
|
|
697
|
-
}
|
|
698
|
-
function runCountFor(index, batchId) {
|
|
699
|
-
return (index ?? []).filter((e) => e.batchId === batchId).length;
|
|
700
|
-
}
|
|
701
|
-
function newestBatchId(index) {
|
|
702
|
-
let best = null;
|
|
703
|
-
for (const e of index ?? []) {
|
|
704
|
-
if (e.batchId && (!best || e.createdAt > best.at)) best = { id: e.batchId, at: e.createdAt };
|
|
705
|
-
}
|
|
706
|
-
return best?.id ?? null;
|
|
707
|
-
}
|
|
708
|
-
function failuresFromIndex(index, batchId) {
|
|
709
|
-
const byCase = /* @__PURE__ */ new Map();
|
|
710
|
-
for (const e of index ?? []) {
|
|
711
|
-
if (e.batchId === batchId && e.batchFailureReason && !byCase.has(e.batchIndex ?? 0)) {
|
|
712
|
-
byCase.set(e.batchIndex ?? 0, e.batchFailureReason);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
return [...byCase.entries()].map(([index2, reason]) => ({ index: index2, reason }));
|
|
716
|
-
}
|
|
717
|
-
async function waitForBatchByIndex(sync, deviceId, opts) {
|
|
718
|
-
const { preBatchIds, expectedRuns, maxWaitMs, log } = opts;
|
|
719
|
-
const start = Date.now();
|
|
720
|
-
let lastPhase = "";
|
|
721
|
-
let sawActive = false;
|
|
722
|
-
let idleStreak = 0;
|
|
723
|
-
while (Date.now() - start < maxWaitMs) {
|
|
724
|
-
let snap2;
|
|
725
|
-
try {
|
|
726
|
-
snap2 = await readPerfSnapshot(sync, deviceId);
|
|
727
|
-
} catch {
|
|
728
|
-
await delay2(POLL_INTERVAL_MS);
|
|
729
|
-
continue;
|
|
730
|
-
}
|
|
731
|
-
const index = snap2.index ?? [];
|
|
732
|
-
const phase = snap2.live?.automation?.phase ?? "idle";
|
|
733
|
-
const completed = snap2.live?.automationCompleted;
|
|
734
|
-
if (completed?.phase === "cancelled" && !preBatchIds.has(completed.batchId)) {
|
|
735
|
-
return { batchId: completed.batchId, failures: [], cancelled: true };
|
|
736
|
-
}
|
|
737
|
-
if (completed?.phase === "done" && !preBatchIds.has(completed.batchId)) {
|
|
738
|
-
return { batchId: completed.batchId, failures: completed.failures ?? failuresFromIndex(index, completed.batchId) };
|
|
739
|
-
}
|
|
740
|
-
if (phase !== "idle") sawActive = true;
|
|
741
|
-
if (phase !== lastPhase) {
|
|
742
|
-
lastPhase = phase;
|
|
743
|
-
log(`automation phase: ${phase}`);
|
|
744
|
-
}
|
|
745
|
-
const newId2 = [...batchIdsIn(index)].find((id) => !preBatchIds.has(id));
|
|
746
|
-
if (newId2) {
|
|
747
|
-
const count = runCountFor(index, newId2);
|
|
748
|
-
if (expectedRuns > 0 && count >= expectedRuns) {
|
|
749
|
-
log(`batch ${newId2} complete (${count}/${expectedRuns} runs saved)`);
|
|
750
|
-
return { batchId: newId2, failures: failuresFromIndex(index, newId2) };
|
|
751
|
-
}
|
|
752
|
-
if (phase === "idle" && sawActive && count > 0) {
|
|
753
|
-
if (++idleStreak >= 3) {
|
|
754
|
-
log(`batch ${newId2} settled (${count} runs, automation idle)`);
|
|
755
|
-
return { batchId: newId2, failures: failuresFromIndex(index, newId2) };
|
|
756
|
-
}
|
|
757
|
-
} else {
|
|
758
|
-
idleStreak = 0;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
await delay2(POLL_INTERVAL_MS);
|
|
762
|
-
}
|
|
763
|
-
const snap = await readPerfSnapshot(sync, deviceId).catch(() => null);
|
|
764
|
-
const newId = snap ? [...batchIdsIn(snap.index)].find((id) => !preBatchIds.has(id)) : void 0;
|
|
765
|
-
if (newId && snap) return { batchId: newId, failures: failuresFromIndex(snap.index, newId), timedOut: true };
|
|
766
|
-
throw new Error(
|
|
767
|
-
`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.`
|
|
768
|
-
);
|
|
769
|
-
}
|
|
770
|
-
function registerPerfMonitorTools(server, sync) {
|
|
771
|
-
server.registerTool(
|
|
772
|
-
"run_benchmark_batch",
|
|
773
|
-
{
|
|
774
|
-
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.",
|
|
775
|
-
inputSchema: {
|
|
776
|
-
targetRoute: z2.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),
|
|
777
|
-
cases: z2.array(caseSchema).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),
|
|
778
|
-
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 "/").'),
|
|
779
|
-
runsPerCase: z2.number().optional().describe("Runs per case; the median becomes the canonical result. Default 3 (1\u201310)."),
|
|
780
|
-
perCaseDurationMs: z2.number().optional().describe("How long to record each run. Default 5000."),
|
|
781
|
-
settleMs: z2.number().optional().describe("Idle gap after navigation before recording. Default 600."),
|
|
782
|
-
coolDownMs: z2.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),
|
|
783
|
-
navTimeoutMs: z2.number().optional().describe("Max wait for navigation per run. Default 5000."),
|
|
784
|
-
discardWarmupRuns: z2.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),
|
|
785
|
-
shuffleCases: z2.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias. Default on for >3 cases."),
|
|
786
|
-
reloadBetweenCases: z2.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),
|
|
787
|
-
deviceId: z2.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),
|
|
788
|
-
maxWaitMs: z2.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")
|
|
789
|
-
}
|
|
790
|
-
},
|
|
791
|
-
async (args) => {
|
|
792
|
-
const log = (m) => process.stderr.write(`[buoy-mcp] ${m}
|
|
793
|
-
`);
|
|
794
|
-
try {
|
|
795
|
-
const deviceId = sync.requireProDevice(args.deviceId);
|
|
796
|
-
const base = await readDeviceSettings(sync, deviceId);
|
|
797
|
-
const { config, warnings } = buildAutomationConfig(args, base);
|
|
798
|
-
const estimateMs = estimateBatchMs(config);
|
|
799
|
-
const maxWaitMs = args.maxWaitMs ?? estimateMs * 3 + 6e4;
|
|
800
|
-
const expectedRuns = config.cases.length * (config.runsPerCase ?? 1);
|
|
801
|
-
await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, deviceId).catch(() => {
|
|
802
|
-
});
|
|
803
|
-
await sync.callAction(PERF_TOOL_ID, "setEnabled", { enabled: true }, deviceId).catch(() => {
|
|
804
|
-
});
|
|
805
|
-
await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, deviceId).catch(() => {
|
|
806
|
-
});
|
|
807
|
-
const before = await readPerfSnapshot(sync, deviceId);
|
|
808
|
-
const preBatchIds = batchIdsIn(before.index);
|
|
809
|
-
log(
|
|
810
|
-
`starting batch: ${config.cases.length} cases \xD7 ${config.runsPerCase} runs (~${Math.round(estimateMs / 1e3)}s estimated) on ${deviceId}`
|
|
811
|
-
);
|
|
812
|
-
await sync.callAction(PERF_TOOL_ID, "startAutomation", { config }, deviceId);
|
|
813
|
-
const outcome = await waitForBatchByIndex(sync, deviceId, {
|
|
814
|
-
preBatchIds,
|
|
815
|
-
expectedRuns,
|
|
816
|
-
maxWaitMs,
|
|
817
|
-
log
|
|
818
|
-
});
|
|
819
|
-
if (outcome.cancelled) {
|
|
820
|
-
await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, deviceId).catch(() => {
|
|
821
|
-
});
|
|
822
|
-
return fail2(`Batch ${outcome.batchId} was cancelled before completing.`);
|
|
823
|
-
}
|
|
824
|
-
await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, deviceId).catch(() => {
|
|
825
|
-
});
|
|
826
|
-
const snap = await readPerfSnapshot(sync, deviceId);
|
|
827
|
-
const ranking = rankBatchFromIndex(snap.index ?? [], outcome.batchId, config.cases, outcome.failures);
|
|
828
|
-
await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, deviceId).catch(() => {
|
|
829
|
-
});
|
|
830
|
-
await sync.callAction(PERF_TOOL_ID, "setEnabled", { enabled: false }, deviceId).catch(() => {
|
|
831
|
-
});
|
|
832
|
-
const md = formatRankingMarkdown(ranking);
|
|
833
|
-
const summary = {
|
|
834
|
-
batchId: outcome.batchId,
|
|
835
|
-
leaders: ranking.leaders,
|
|
836
|
-
balanced: ranking.balanced,
|
|
837
|
-
flagged: ranking.flagged,
|
|
838
|
-
rows: ranking.rows,
|
|
839
|
-
failures: outcome.failures,
|
|
840
|
-
warnings,
|
|
841
|
-
estimateMs,
|
|
842
|
-
...outcome.timedOut ? { note: "Wait cap hit \u2014 results may be partial (some runs may still be saving)." } : {}
|
|
843
|
-
};
|
|
844
|
-
return text(`${md}
|
|
845
|
-
|
|
846
|
-
\`\`\`json
|
|
847
|
-
${JSON.stringify(summary, null, 2)}
|
|
848
|
-
\`\`\``);
|
|
849
|
-
} catch (e) {
|
|
850
|
-
return fail2(errMsg2(e));
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
);
|
|
854
|
-
server.registerTool(
|
|
855
|
-
"get_automation_status",
|
|
856
|
-
{
|
|
857
|
-
description: "Get the perf-monitor automation batch status (current phase + last completed batch) without waiting.",
|
|
858
|
-
inputSchema: { deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).") }
|
|
859
|
-
},
|
|
860
|
-
async ({ deviceId }) => {
|
|
861
|
-
try {
|
|
862
|
-
const target = sync.requireProDevice(deviceId);
|
|
863
|
-
const snap = await readPerfSnapshot(sync, target);
|
|
864
|
-
return json2({
|
|
865
|
-
deviceId: target,
|
|
866
|
-
automation: snap.live?.automation ?? { phase: "idle" },
|
|
867
|
-
automationCompleted: snap.live?.automationCompleted ?? null
|
|
868
|
-
});
|
|
869
|
-
} catch (e) {
|
|
870
|
-
return fail2(errMsg2(e));
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
);
|
|
874
|
-
server.registerTool(
|
|
875
|
-
"wait_for_automation",
|
|
876
|
-
{
|
|
877
|
-
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).",
|
|
878
|
-
inputSchema: {
|
|
879
|
-
deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected)."),
|
|
880
|
-
maxWaitMs: z2.number().optional().describe("Max time to wait. Default 600000 (10 min).")
|
|
881
|
-
}
|
|
882
|
-
},
|
|
883
|
-
async ({ deviceId, maxWaitMs }) => {
|
|
884
|
-
const log = (m) => process.stderr.write(`[buoy-mcp] ${m}
|
|
885
|
-
`);
|
|
886
|
-
const cap = maxWaitMs ?? 6e5;
|
|
887
|
-
try {
|
|
888
|
-
const target = sync.requireProDevice(deviceId);
|
|
889
|
-
const start = Date.now();
|
|
890
|
-
let sawActive = false;
|
|
891
|
-
let idleStreak = 0;
|
|
892
|
-
let lastCount = -1;
|
|
893
|
-
let resolved = null;
|
|
894
|
-
while (Date.now() - start < cap) {
|
|
895
|
-
let snap2;
|
|
896
|
-
try {
|
|
897
|
-
snap2 = await readPerfSnapshot(sync, target);
|
|
898
|
-
} catch {
|
|
899
|
-
await delay2(POLL_INTERVAL_MS);
|
|
900
|
-
continue;
|
|
901
|
-
}
|
|
902
|
-
const completed = snap2.live?.automationCompleted;
|
|
903
|
-
if (completed?.phase === "cancelled") return fail2(`Batch ${completed.batchId} was cancelled.`);
|
|
904
|
-
if (completed?.phase === "done") {
|
|
905
|
-
resolved = { batchId: completed.batchId, failures: completed.failures ?? failuresFromIndex(snap2.index, completed.batchId) };
|
|
906
|
-
break;
|
|
907
|
-
}
|
|
908
|
-
const phase = snap2.live?.automation?.phase ?? "idle";
|
|
909
|
-
if (phase !== "idle") sawActive = true;
|
|
910
|
-
const newest = newestBatchId(snap2.index);
|
|
911
|
-
if (phase === "idle" && newest) {
|
|
912
|
-
const count = runCountFor(snap2.index, newest);
|
|
913
|
-
if (count === lastCount) {
|
|
914
|
-
if (sawActive && ++idleStreak >= 3) {
|
|
915
|
-
resolved = { batchId: newest, failures: failuresFromIndex(snap2.index, newest) };
|
|
916
|
-
break;
|
|
917
|
-
}
|
|
918
|
-
if (!sawActive && Date.now() - start > 4e3) {
|
|
919
|
-
resolved = { batchId: newest, failures: failuresFromIndex(snap2.index, newest) };
|
|
920
|
-
break;
|
|
921
|
-
}
|
|
922
|
-
} else {
|
|
923
|
-
idleStreak = 0;
|
|
924
|
-
lastCount = count;
|
|
925
|
-
}
|
|
926
|
-
} else {
|
|
927
|
-
idleStreak = 0;
|
|
928
|
-
}
|
|
929
|
-
await delay2(POLL_INTERVAL_MS);
|
|
930
|
-
}
|
|
931
|
-
if (!resolved) return fail2("No automation batch completed within the wait window.");
|
|
932
|
-
await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, target).catch(() => {
|
|
933
|
-
});
|
|
934
|
-
const snap = await readPerfSnapshot(sync, target);
|
|
935
|
-
const ranking = rankBatchFromIndex(snap.index ?? [], resolved.batchId, void 0, resolved.failures);
|
|
936
|
-
await sync.callAction(PERF_TOOL_ID, "acknowledgeAutomation", {}, target).catch(() => {
|
|
937
|
-
});
|
|
938
|
-
return text(`${formatRankingMarkdown(ranking)}
|
|
939
|
-
|
|
940
|
-
\`\`\`json
|
|
941
|
-
${JSON.stringify(ranking, null, 2)}
|
|
942
|
-
\`\`\``);
|
|
943
|
-
} catch (e) {
|
|
944
|
-
return fail2(errMsg2(e));
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
);
|
|
948
|
-
server.registerTool(
|
|
949
|
-
"get_batch_report",
|
|
950
|
-
{
|
|
951
|
-
description: "Re-rank a previously-run benchmark batch by its batchId (from the saved recordings index).",
|
|
952
|
-
inputSchema: {
|
|
953
|
-
batchId: z2.string().describe("The batch id to report on."),
|
|
954
|
-
deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
955
|
-
}
|
|
956
|
-
},
|
|
957
|
-
async ({ batchId, deviceId }) => {
|
|
958
|
-
try {
|
|
959
|
-
const target = sync.requireProDevice(deviceId);
|
|
960
|
-
await sync.callAction(PERF_TOOL_ID, "refreshIndex", {}, target).catch(() => {
|
|
961
|
-
});
|
|
962
|
-
const snap = await readPerfSnapshot(sync, target);
|
|
963
|
-
const entries = (snap.index ?? []).filter((e) => e.batchId === batchId);
|
|
964
|
-
if (entries.length === 0) return fail2(`No saved runs found for batch "${batchId}".`);
|
|
965
|
-
const ranking = rankBatchFromIndex(snap.index ?? [], batchId);
|
|
966
|
-
return text(`${formatRankingMarkdown(ranking)}
|
|
967
|
-
|
|
968
|
-
\`\`\`json
|
|
969
|
-
${JSON.stringify(ranking, null, 2)}
|
|
970
|
-
\`\`\``);
|
|
971
|
-
} catch (e) {
|
|
972
|
-
return fail2(errMsg2(e));
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
);
|
|
976
|
-
server.registerTool(
|
|
977
|
-
"compare_reports",
|
|
978
|
-
{
|
|
979
|
-
description: "Compare two saved benchmark runs head-to-head (avg + p95 for FPS/CPU/memory, jank, with deltas).",
|
|
980
|
-
inputSchema: {
|
|
981
|
-
reportIdA: z2.string().describe("First (baseline) report id."),
|
|
982
|
-
reportIdB: z2.string().describe("Second report id to compare against the baseline."),
|
|
983
|
-
deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
984
|
-
}
|
|
985
|
-
},
|
|
986
|
-
async ({ reportIdA, reportIdB, deviceId }) => {
|
|
987
|
-
try {
|
|
988
|
-
const target = sync.requireProDevice(deviceId);
|
|
989
|
-
const [a, b] = await Promise.all([
|
|
990
|
-
sync.callAction(PERF_TOOL_ID, "loadReport", { id: reportIdA }, target),
|
|
991
|
-
sync.callAction(PERF_TOOL_ID, "loadReport", { id: reportIdB }, target)
|
|
992
|
-
]);
|
|
993
|
-
if (!a) return fail2(`Report "${reportIdA}" not found.`);
|
|
994
|
-
if (!b) return fail2(`Report "${reportIdB}" not found.`);
|
|
995
|
-
return json2(
|
|
996
|
-
compareReportStats(
|
|
997
|
-
{ name: a.metadata.name, stats: a.stats },
|
|
998
|
-
{ name: b.metadata.name, stats: b.stats }
|
|
999
|
-
)
|
|
1000
|
-
);
|
|
1001
|
-
} catch (e) {
|
|
1002
|
-
return fail2(errMsg2(e));
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
);
|
|
1006
|
-
server.registerTool(
|
|
1007
|
-
"get_benchmark_settings",
|
|
1008
|
-
{
|
|
1009
|
-
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.",
|
|
1010
|
-
inputSchema: {
|
|
1011
|
-
pages: z2.number().optional().describe("How many pages/variants you plan to test, for a total-time estimate. Default 1."),
|
|
1012
|
-
deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
1013
|
-
}
|
|
1014
|
-
},
|
|
1015
|
-
async ({ pages, deviceId }) => {
|
|
1016
|
-
try {
|
|
1017
|
-
const target = sync.requireProDevice(deviceId);
|
|
1018
|
-
const settings = await readDeviceSettings(sync, target);
|
|
1019
|
-
const n = Math.max(1, Math.round(pages ?? 1));
|
|
1020
|
-
const { config } = buildAutomationConfig(
|
|
1021
|
-
{ targetRoute: "/_preview", cases: Array.from({ length: n }, (_, i) => ({ name: `page ${i + 1}` })) },
|
|
1022
|
-
settings
|
|
1023
|
-
);
|
|
1024
|
-
const source = Object.keys(settings).length > 0 ? "this device's saved settings" : "default profile (none saved)";
|
|
1025
|
-
return text(
|
|
1026
|
-
`**Benchmark settings (${source}):** ${describeBenchmarkSettings(config)}
|
|
1027
|
-
|
|
1028
|
-
\`\`\`json
|
|
1029
|
-
${JSON.stringify(settings, null, 2)}
|
|
1030
|
-
\`\`\``
|
|
1031
|
-
);
|
|
1032
|
-
} catch (e) {
|
|
1033
|
-
return fail2(errMsg2(e));
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
);
|
|
1037
|
-
server.registerTool(
|
|
1038
|
-
"set_benchmark_settings",
|
|
1039
|
-
{
|
|
1040
|
-
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.`,
|
|
1041
|
-
inputSchema: {
|
|
1042
|
-
perCaseDurationMs: z2.number().optional().describe("Recording length per page (ms)."),
|
|
1043
|
-
settleMs: z2.number().optional().describe("Idle after navigation before recording (ms)."),
|
|
1044
|
-
coolDownMs: z2.number().optional().describe("Idle between runs/cases (ms)."),
|
|
1045
|
-
navTimeoutMs: z2.number().optional().describe("Max wait for navigation per run (ms)."),
|
|
1046
|
-
runsPerCase: z2.number().optional().describe("Runs per page; median is the canonical result (1\u201310)."),
|
|
1047
|
-
discardWarmupRuns: z2.number().optional().describe("Discard first N runs of each page before the median."),
|
|
1048
|
-
shuffleCases: z2.boolean().optional().describe("Interleave + shuffle case order to cancel thermal bias."),
|
|
1049
|
-
reloadBetweenCases: z2.boolean().optional().describe("Fully reload JS between pages (needs <AutomationResumer/> mounted)."),
|
|
1050
|
-
deviceId: z2.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
1051
|
-
}
|
|
1052
|
-
},
|
|
1053
|
-
async (args) => {
|
|
1054
|
-
try {
|
|
1055
|
-
const target = sync.requireProDevice(args.deviceId);
|
|
1056
|
-
const current = await sync.callAction(PERF_TOOL_ID, "getAutomationConfig", {}, target) ?? {};
|
|
1057
|
-
const keys = [
|
|
1058
|
-
"perCaseDurationMs",
|
|
1059
|
-
"settleMs",
|
|
1060
|
-
"coolDownMs",
|
|
1061
|
-
"navTimeoutMs",
|
|
1062
|
-
"runsPerCase",
|
|
1063
|
-
"discardWarmupRuns",
|
|
1064
|
-
"shuffleCases",
|
|
1065
|
-
"reloadBetweenCases"
|
|
1066
|
-
];
|
|
1067
|
-
const overrides = {};
|
|
1068
|
-
for (const k of keys) {
|
|
1069
|
-
const v = args[k];
|
|
1070
|
-
if (v !== void 0) overrides[k] = v;
|
|
1071
|
-
}
|
|
1072
|
-
if (Object.keys(overrides).length === 0) {
|
|
1073
|
-
return fail2("No settings provided to save. Pass at least one field (e.g. perCaseDurationMs).");
|
|
1074
|
-
}
|
|
1075
|
-
const next = { ...current, ...overrides };
|
|
1076
|
-
const saved = await sync.callAction(PERF_TOOL_ID, "setAutomationConfig", { config: next }, target);
|
|
1077
|
-
const settings = await readDeviceSettings(sync, target);
|
|
1078
|
-
const { config } = buildAutomationConfig(
|
|
1079
|
-
{ targetRoute: "/_preview", cases: [{ name: "page 1" }] },
|
|
1080
|
-
settings
|
|
1081
|
-
);
|
|
1082
|
-
return text(
|
|
1083
|
-
`Saved. **New benchmark settings:** ${describeBenchmarkSettings(config)}
|
|
1084
|
-
|
|
1085
|
-
\`\`\`json
|
|
1086
|
-
${JSON.stringify(saved ?? settings, null, 2)}
|
|
1087
|
-
\`\`\``
|
|
1088
|
-
);
|
|
1089
|
-
} catch (e) {
|
|
1090
|
-
return fail2(errMsg2(e));
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// src/server.ts
|
|
1097
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1098
|
-
import { z as z3 } from "zod";
|
|
1099
|
-
var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
|
|
1100
|
-
|
|
1101
|
-
Buoy tools live inside the app and sync to a local broker; this server connects to that
|
|
1102
|
-
broker as a dashboard client. Start by calling \`list_devices\` to see which device(s) are
|
|
1103
|
-
connected and which tools (e.g. "perf-monitor", "network", "react-query") they expose, then:
|
|
1104
|
-
- \`list_tool_actions\` to see what a tool can do,
|
|
1105
|
-
- \`get_snapshot\` to read a tool's current state,
|
|
1106
|
-
- \`call_action\` to invoke any tool action generically.
|
|
1107
|
-
|
|
1108
|
-
Higher-level tools (e.g. perf-monitor's \`run_benchmark_batch\`) wrap these for common workflows.
|
|
1109
|
-
If no device is connected, open the app with Buoy devtools running and try again.
|
|
1110
|
-
|
|
1111
|
-
The Buoy MCP is a Pro feature: \`list_devices\` is always available, but data/action tools
|
|
1112
|
-
require the connected app to have an active Buoy Pro license. If they error with "Buoy Pro
|
|
1113
|
-
required", tell the user to upgrade to Buoy Pro in the app \u2014 don't try to work around it.`;
|
|
1114
|
-
function createMcpServer(sync) {
|
|
1115
|
-
const server = new McpServer(
|
|
1116
|
-
{ name: "buoy", version: "3.0.2" },
|
|
1117
|
-
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
|
|
1118
|
-
);
|
|
1119
|
-
registerGenericTools(server, sync);
|
|
1120
|
-
registerPerfMonitorTools(server, sync);
|
|
1121
|
-
return server;
|
|
1122
|
-
}
|
|
1123
|
-
var deviceIdField = {
|
|
1124
|
-
deviceId: z3.string().optional().describe(
|
|
1125
|
-
'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
|
|
1126
|
-
)
|
|
1127
|
-
};
|
|
1128
|
-
|
|
1129
|
-
export {
|
|
1130
|
-
BROKER_PORT,
|
|
1131
|
-
BROKER_URL,
|
|
1132
|
-
DEFAULT_ACTION_TIMEOUT_MS,
|
|
1133
|
-
DEFAULT_SNAPSHOT_TIMEOUT_MS,
|
|
1134
|
-
DASHBOARD_NAME,
|
|
1135
|
-
ALL_DEVICES,
|
|
1136
|
-
SyncClient,
|
|
1137
|
-
registerGenericTools,
|
|
1138
|
-
buildAutomationConfig,
|
|
1139
|
-
estimateBatchMs,
|
|
1140
|
-
rankBatchFromIndex,
|
|
1141
|
-
formatRankingMarkdown,
|
|
1142
|
-
compareReportStats,
|
|
1143
|
-
registerPerfMonitorTools,
|
|
1144
|
-
createMcpServer
|
|
1145
|
-
};
|