@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/index.js CHANGED
@@ -36,15 +36,28 @@ __export(src_exports, {
36
36
  DASHBOARD_NAME: () => DASHBOARD_NAME,
37
37
  DEFAULT_ACTION_TIMEOUT_MS: () => DEFAULT_ACTION_TIMEOUT_MS,
38
38
  DEFAULT_SNAPSHOT_TIMEOUT_MS: () => DEFAULT_SNAPSHOT_TIMEOUT_MS,
39
+ PACKAGE_NAME: () => PACKAGE_NAME,
39
40
  SyncClient: () => SyncClient,
41
+ VERSION: () => VERSION,
40
42
  buildAutomationConfig: () => buildAutomationConfig,
41
43
  compareReportStats: () => compareReportStats,
42
44
  createMcpServer: () => createMcpServer,
43
45
  estimateBatchMs: () => estimateBatchMs,
44
46
  formatRankingMarkdown: () => formatRankingMarkdown,
47
+ getUpdateNotice: () => getUpdateNotice,
45
48
  rankBatchFromIndex: () => rankBatchFromIndex,
49
+ registerConsoleTools: () => registerConsoleTools,
50
+ registerEventsTools: () => registerEventsTools,
46
51
  registerGenericTools: () => registerGenericTools,
47
- registerPerfMonitorTools: () => registerPerfMonitorTools
52
+ registerJotaiTools: () => registerJotaiTools,
53
+ registerPerfMonitorTools: () => registerPerfMonitorTools,
54
+ registerReactQueryTools: () => registerReactQueryTools,
55
+ registerReduxTools: () => registerReduxTools,
56
+ registerRoutesTools: () => registerRoutesTools,
57
+ registerScreenshotTools: () => registerScreenshotTools,
58
+ registerStorageTools: () => registerStorageTools,
59
+ registerZustandTools: () => registerZustandTools,
60
+ runInit: () => runInit
48
61
  });
49
62
  module.exports = __toCommonJS(src_exports);
50
63
 
@@ -347,10 +360,85 @@ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
347
360
 
348
361
  // src/server.ts
349
362
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
350
- var import_zod3 = require("zod");
363
+ var import_zod13 = require("zod");
351
364
 
352
365
  // src/tools/generic.ts
353
366
  var import_zod = require("zod");
367
+
368
+ // src/update/checkForUpdate.ts
369
+ var import_node_fs = require("fs");
370
+ var import_node_os = __toESM(require("os"));
371
+ var import_node_path = __toESM(require("path"));
372
+
373
+ // src/version.ts
374
+ var VERSION = "5.0.0" ? "5.0.0" : "0.0.0-dev";
375
+ var PACKAGE_NAME = "@buoy-gg/mcp";
376
+
377
+ // src/update/checkForUpdate.ts
378
+ var CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
379
+ var FETCH_TIMEOUT_MS = 1500;
380
+ var REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
381
+ var CACHE_FILE = import_node_path.default.join(import_node_os.default.tmpdir(), "buoy-mcp-update-check.json");
382
+ function isNewer(a, b) {
383
+ const parse = (v) => v.split("-")[0].split(".").map((n) => Number(n) || 0);
384
+ const pa = parse(a);
385
+ const pb = parse(b);
386
+ for (let i = 0; i < 3; i++) {
387
+ const x = pa[i] ?? 0;
388
+ const y = pb[i] ?? 0;
389
+ if (x > y) return true;
390
+ if (x < y) return false;
391
+ }
392
+ return false;
393
+ }
394
+ async function readCache() {
395
+ try {
396
+ const raw = await import_node_fs.promises.readFile(CACHE_FILE, "utf8");
397
+ const parsed = JSON.parse(raw);
398
+ if (typeof parsed.checkedAt === "number" && typeof parsed.latest === "string") {
399
+ return parsed;
400
+ }
401
+ } catch {
402
+ }
403
+ return null;
404
+ }
405
+ async function fetchLatest(now) {
406
+ const controller = new AbortController();
407
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
408
+ try {
409
+ const res = await fetch(REGISTRY_URL, { signal: controller.signal });
410
+ if (!res.ok) return null;
411
+ const body = await res.json();
412
+ const latest = typeof body.version === "string" ? body.version : null;
413
+ if (latest) {
414
+ await import_node_fs.promises.writeFile(CACHE_FILE, JSON.stringify({ checkedAt: now, latest })).catch(() => {
415
+ });
416
+ }
417
+ return latest;
418
+ } catch {
419
+ return null;
420
+ } finally {
421
+ clearTimeout(timer);
422
+ }
423
+ }
424
+ async function resolveLatest(now) {
425
+ const cache = await readCache();
426
+ if (cache && now - cache.checkedAt < CHECK_TTL_MS) return cache.latest;
427
+ return fetchLatest(now);
428
+ }
429
+ async function getUpdateNotice(now = Date.now()) {
430
+ if (VERSION === "0.0.0-dev") return null;
431
+ try {
432
+ const latest = await resolveLatest(now);
433
+ if (latest && isNewer(latest, VERSION)) {
434
+ 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.`;
435
+ }
436
+ } catch {
437
+ }
438
+ return null;
439
+ }
440
+
441
+ // src/tools/generic.ts
354
442
  function json(value) {
355
443
  return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
356
444
  }
@@ -365,11 +453,13 @@ function registerGenericTools(server, sync) {
365
453
  inputSchema: {}
366
454
  },
367
455
  async () => {
456
+ const updateNotice = await getUpdateNotice();
368
457
  const devices = sync.listDevices();
369
458
  if (devices.length === 0) {
370
459
  return json({
371
460
  devices: [],
372
- hint: "No devices connected. Open the app with Buoy devtools running, then retry."
461
+ hint: "No devices connected. Open the app with Buoy devtools running, then retry.",
462
+ ...updateNotice ? { updateAvailable: updateNotice } : {}
373
463
  });
374
464
  }
375
465
  const anyPro = devices.some((d) => d.isConnected && d.isPro);
@@ -377,7 +467,8 @@ function registerGenericTools(server, sync) {
377
467
  devices,
378
468
  ...anyPro ? {} : {
379
469
  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."
380
- }
470
+ },
471
+ ...updateNotice ? { updateAvailable: updateNotice } : {}
381
472
  });
382
473
  }
383
474
  );
@@ -1147,14 +1238,925 @@ ${JSON.stringify(saved ?? settings, null, 2)}
1147
1238
  );
1148
1239
  }
1149
1240
 
1241
+ // src/tools/packs/events.ts
1242
+ var import_zod3 = require("zod");
1243
+ var EVENTS_TOOL_ID = "events";
1244
+ var text2 = (s) => ({ content: [{ type: "text", text: s }] });
1245
+ var fail3 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1246
+ var errMsg3 = (e) => e instanceof Error ? e.message : String(e);
1247
+ var SOURCE_ALIASES = {
1248
+ storage: ["storage-async", "storage-mmkv"],
1249
+ "react-query": ["react-query", "react-query-query", "react-query-mutation"]
1250
+ };
1251
+ function expandSources(sources) {
1252
+ if (!sources || sources.length === 0) return void 0;
1253
+ return [...new Set(sources.flatMap((s) => SOURCE_ALIASES[s] ?? [s]))];
1254
+ }
1255
+ function registerEventsTools(server, sync) {
1256
+ server.registerTool(
1257
+ "get_events",
1258
+ {
1259
+ 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.",
1260
+ inputSchema: {
1261
+ limit: import_zod3.z.number().optional().describe("Most-recent N events to include. Default 25."),
1262
+ sources: import_zod3.z.array(import_zod3.z.string()).optional().describe('Only these sources. Coarse names accepted: "network", "redux", "react-query", "storage", "route", "render", "zustand", "jotai". Omit for all.'),
1263
+ status: import_zod3.z.enum(["all", "errors", "success", "pending"]).optional().describe("Filter by status. Default all."),
1264
+ includeData: import_zod3.z.boolean().optional().describe("Include each event's full raw payload (HEAVY \u2014 network bodies, state trees). Default false (compact)."),
1265
+ format: import_zod3.z.enum(["markdown", "json", "text", "mermaid"]).optional().describe("Output format. Default markdown."),
1266
+ preset: import_zod3.z.enum(["llm", "bugReport", "json", "errors", "minimal", "mermaid"]).optional().describe("Start from a named Copy-Settings preset (then your other args override it)."),
1267
+ deviceId: import_zod3.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1268
+ }
1269
+ },
1270
+ async (args) => {
1271
+ try {
1272
+ const target = sync.requireProDevice(args.deviceId);
1273
+ const settings = {};
1274
+ const sources = expandSources(args.sources);
1275
+ if (sources) settings.filterSources = sources;
1276
+ if (args.status) settings.filterMode = args.status;
1277
+ if (args.includeData !== void 0) settings.includeEventData = args.includeData;
1278
+ if (args.format) settings.format = args.format === "text" ? "plaintext" : args.format;
1279
+ const res = await sync.callAction(
1280
+ EVENTS_TOOL_ID,
1281
+ "exportEvents",
1282
+ { preset: args.preset, settings, limit: args.limit ?? 25 },
1283
+ target
1284
+ );
1285
+ if (!res || typeof res.output !== "string") {
1286
+ return fail3(
1287
+ "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)."
1288
+ );
1289
+ }
1290
+ const more = res.includedData ? "" : " \u2014 compact view; pass includeData:true, a specific source, or a bigger limit for full payloads";
1291
+ const note = `
1292
+
1293
+ _Showing ${res.returned} of ${res.totalAvailable} captured event(s)${more}._`;
1294
+ return text2((res.output || "(no events captured)") + note);
1295
+ } catch (e) {
1296
+ return fail3(errMsg3(e));
1297
+ }
1298
+ }
1299
+ );
1300
+ }
1301
+
1302
+ // src/tools/packs/reactQuery.ts
1303
+ var import_zod4 = require("zod");
1304
+ var RQ_TOOL_ID = "react-query";
1305
+ var text3 = (s) => ({ content: [{ type: "text", text: s }] });
1306
+ var fail4 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1307
+ var errMsg4 = (e) => e instanceof Error ? e.message : String(e);
1308
+ var STATUS_ICON = {
1309
+ fresh: "\u{1F7E2}",
1310
+ stale: "\u{1F7E1}",
1311
+ fetching: "\u{1F504}",
1312
+ error: "\u{1F534}",
1313
+ inactive: "\u26AA\uFE0F",
1314
+ paused: "\u23F8\uFE0F",
1315
+ disabled: "\u{1F6AB}"
1316
+ };
1317
+ function relTime(ts) {
1318
+ if (!ts) return "\u2014";
1319
+ const s = Math.round((Date.now() - ts) / 1e3);
1320
+ if (s < 60) return `${s}s ago`;
1321
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
1322
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
1323
+ return `${Math.round(s / 86400)}d ago`;
1324
+ }
1325
+ function registerReactQueryTools(server, sync) {
1326
+ server.registerTool(
1327
+ "get_react_query",
1328
+ {
1329
+ 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.",
1330
+ inputSchema: {
1331
+ includeData: import_zod4.z.boolean().optional().describe("Include each query's full cached data payload (HEAVY). Default false."),
1332
+ staleOnly: import_zod4.z.boolean().optional().describe("Only stale queries. Default false."),
1333
+ limit: import_zod4.z.number().optional().describe("Most-recently-updated N queries. Default 25."),
1334
+ deviceId: import_zod4.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1335
+ }
1336
+ },
1337
+ async (args) => {
1338
+ try {
1339
+ const target = sync.requireProDevice(args.deviceId);
1340
+ const res = await sync.callAction(
1341
+ RQ_TOOL_ID,
1342
+ "listQueries",
1343
+ { includeData: args.includeData ?? false, staleOnly: args.staleOnly ?? false, limit: args.limit ?? 25 },
1344
+ target
1345
+ );
1346
+ if (!res || !Array.isArray(res.queries)) {
1347
+ return fail4(
1348
+ "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)."
1349
+ );
1350
+ }
1351
+ if (res.queries.length === 0) {
1352
+ return text3(`React Query cache is empty (0 of ${res.total} queries${args.staleOnly ? " match staleOnly" : ""}).`);
1353
+ }
1354
+ const lines = res.queries.map((q, i) => {
1355
+ const icon = STATUS_ICON[q.status] ?? "\u2022";
1356
+ const base = `${i + 1}. ${icon} ${q.status} \xB7 \`${q.queryHash}\` \xB7 ${q.observers} obs \xB7 ${relTime(q.updatedAt)}`;
1357
+ const errSuffix = q.error ? ` \u2014 \u26A0\uFE0F ${q.error}` : "";
1358
+ const dataSuffix = res.includedData ? `
1359
+ data: ${JSON.stringify(q.data)}` : "";
1360
+ return base + errSuffix + dataSuffix;
1361
+ });
1362
+ const staleCount = res.queries.filter((q) => q.isStale).length;
1363
+ const header = `**React Query cache \u2014 ${res.total} queries (showing ${res.returned}, ${staleCount} stale):**`;
1364
+ 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._";
1365
+ return text3(`${header}
1366
+
1367
+ ${lines.join("\n")}${note}`);
1368
+ } catch (e) {
1369
+ return fail4(errMsg4(e));
1370
+ }
1371
+ }
1372
+ );
1373
+ server.registerTool(
1374
+ "react_query_action",
1375
+ {
1376
+ 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.",
1377
+ inputSchema: {
1378
+ action: import_zod4.z.enum(["invalidate", "refetch", "reset", "remove", "clear"]).describe("invalidate/refetch/reset/remove a single query (needs queryHash); clear empties the whole query cache."),
1379
+ queryHash: import_zod4.z.string().optional().describe("The target query's hash (from get_react_query, shown in backticks). Required for all actions except clear."),
1380
+ deviceId: import_zod4.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1381
+ }
1382
+ },
1383
+ async (args) => {
1384
+ try {
1385
+ const target = sync.requireProDevice(args.deviceId);
1386
+ if (args.action === "clear") {
1387
+ await sync.callAction(RQ_TOOL_ID, "clearQueryCache", {}, target);
1388
+ return text3("Cleared the entire React Query cache.");
1389
+ }
1390
+ if (!args.queryHash) {
1391
+ return fail4(`'${args.action}' needs a queryHash. Call get_react_query first and use a row's hash (in backticks).`);
1392
+ }
1393
+ await sync.callAction(RQ_TOOL_ID, args.action, { queryHash: args.queryHash }, target);
1394
+ return text3(`Ran ${args.action} on query \`${args.queryHash}\`.`);
1395
+ } catch (e) {
1396
+ return fail4(errMsg4(e));
1397
+ }
1398
+ }
1399
+ );
1400
+ }
1401
+
1402
+ // src/tools/packs/zustand.ts
1403
+ var import_zod5 = require("zod");
1404
+ var ZUSTAND_TOOL_ID = "zustand";
1405
+ var text4 = (s) => ({ content: [{ type: "text", text: s }] });
1406
+ var fail5 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1407
+ var errMsg5 = (e) => e instanceof Error ? e.message : String(e);
1408
+ function registerZustandTools(server, sync) {
1409
+ server.registerTool(
1410
+ "get_zustand_state",
1411
+ {
1412
+ 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'].)",
1413
+ inputSchema: {
1414
+ includeValues: import_zod5.z.boolean().optional().describe("Include each store's full current state object (HEAVY). Default false (keys only)."),
1415
+ limit: import_zod5.z.number().optional().describe("First N stores. Default all."),
1416
+ deviceId: import_zod5.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1417
+ }
1418
+ },
1419
+ async (args) => {
1420
+ try {
1421
+ const target = sync.requireProDevice(args.deviceId);
1422
+ const res = await sync.callAction(
1423
+ ZUSTAND_TOOL_ID,
1424
+ "listStores",
1425
+ { includeValues: args.includeValues ?? false, limit: args.limit },
1426
+ target
1427
+ );
1428
+ if (!res || !Array.isArray(res.stores)) {
1429
+ return fail5(
1430
+ "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)."
1431
+ );
1432
+ }
1433
+ if (res.stores.length === 0) {
1434
+ return text4("No Zustand stores are registered (call watchStores to register them).");
1435
+ }
1436
+ const lines = res.stores.map((s, i) => {
1437
+ const persist = s.isPersisted ? ` \xB7 persisted${s.persistName ? ` (${s.persistName})` : ""}` : "";
1438
+ const body = res.includedValues ? `
1439
+ ${JSON.stringify(s.currentState)}` : ` \xB7 keys: ${(s.keys ?? []).join(", ") || "\u2014"}`;
1440
+ return `${i + 1}. **${s.name}** \xB7 ${s.changes} changes${persist}${body}`;
1441
+ });
1442
+ const note = res.includedValues ? "" : "\n\n_Compact view (keys only). Pass includeValues:true for full state. Mutate with zustand_action({store, state})._";
1443
+ return text4(`**Zustand stores \u2014 ${res.total} (showing ${res.returned}):**
1444
+
1445
+ ${lines.join("\n")}${note}`);
1446
+ } catch (e) {
1447
+ return fail5(errMsg5(e));
1448
+ }
1449
+ }
1450
+ );
1451
+ server.registerTool(
1452
+ "zustand_action",
1453
+ {
1454
+ 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.",
1455
+ inputSchema: {
1456
+ store: import_zod5.z.string().describe("Store name to target (from get_zustand_state)."),
1457
+ state: import_zod5.z.record(import_zod5.z.string(), import_zod5.z.unknown()).describe("The new state object."),
1458
+ replace: import_zod5.z.boolean().optional().describe("Replace the whole state (default true) or merge a partial (false)."),
1459
+ deviceId: import_zod5.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1460
+ }
1461
+ },
1462
+ async (args) => {
1463
+ try {
1464
+ const target = sync.requireProDevice(args.deviceId);
1465
+ await sync.callAction(
1466
+ ZUSTAND_TOOL_ID,
1467
+ "setState",
1468
+ { storeName: args.store, state: args.state, replace: args.replace ?? true },
1469
+ target
1470
+ );
1471
+ return text4(`Set state on Zustand store "${args.store}" (${args.replace === false ? "merged" : "replaced"}).`);
1472
+ } catch (e) {
1473
+ return fail5(errMsg5(e));
1474
+ }
1475
+ }
1476
+ );
1477
+ }
1478
+
1479
+ // src/tools/packs/jotai.ts
1480
+ var import_zod6 = require("zod");
1481
+ var JOTAI_TOOL_ID = "jotai";
1482
+ var text5 = (s) => ({ content: [{ type: "text", text: s }] });
1483
+ var fail6 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1484
+ var errMsg6 = (e) => e instanceof Error ? e.message : String(e);
1485
+ function registerJotaiTools(server, sync) {
1486
+ server.registerTool(
1487
+ "get_jotai_state",
1488
+ {
1489
+ 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'].)",
1490
+ inputSchema: {
1491
+ includeValues: import_zod6.z.boolean().optional().describe("Include each atom's current value (HEAVY). Default false."),
1492
+ limit: import_zod6.z.number().optional().describe("First N atoms. Default all."),
1493
+ deviceId: import_zod6.z.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
+ JOTAI_TOOL_ID,
1501
+ "listAtoms",
1502
+ { includeValues: args.includeValues ?? false, limit: args.limit },
1503
+ target
1504
+ );
1505
+ if (!res || !Array.isArray(res.atoms)) {
1506
+ return fail6(
1507
+ "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)."
1508
+ );
1509
+ }
1510
+ if (res.atoms.length === 0) {
1511
+ return text5("No Jotai atoms are being watched (call watchAtoms to register them).");
1512
+ }
1513
+ const lines = res.atoms.map((a, i) => {
1514
+ const kind = a.writable ? "writable" : "read-only";
1515
+ const value = res.includedValues ? `
1516
+ ${JSON.stringify(a.currentValue)}` : "";
1517
+ return `${i + 1}. **${a.label}** \xB7 ${a.changes} changes \xB7 ${kind}${value}`;
1518
+ });
1519
+ const note = res.includedValues ? "" : "\n\n_Compact view. Pass includeValues:true for current values. Set a writable atom with jotai_action({atom, value})._";
1520
+ return text5(`**Jotai atoms \u2014 ${res.total} (showing ${res.returned}):**
1521
+
1522
+ ${lines.join("\n")}${note}`);
1523
+ } catch (e) {
1524
+ return fail6(errMsg6(e));
1525
+ }
1526
+ }
1527
+ );
1528
+ server.registerTool(
1529
+ "jotai_action",
1530
+ {
1531
+ 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.",
1532
+ inputSchema: {
1533
+ atom: import_zod6.z.string().describe("Atom label to set (from get_jotai_state; must be writable)."),
1534
+ value: import_zod6.z.unknown().describe("The new value (any JSON \u2014 number, string, object, etc.)."),
1535
+ deviceId: import_zod6.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1536
+ }
1537
+ },
1538
+ async (args) => {
1539
+ try {
1540
+ const target = sync.requireProDevice(args.deviceId);
1541
+ await sync.callAction(JOTAI_TOOL_ID, "setAtom", { label: args.atom, value: args.value }, target);
1542
+ return text5(`Set Jotai atom "${args.atom}" to ${JSON.stringify(args.value)}.`);
1543
+ } catch (e) {
1544
+ return fail6(errMsg6(e));
1545
+ }
1546
+ }
1547
+ );
1548
+ }
1549
+
1550
+ // src/tools/packs/redux.ts
1551
+ var import_zod7 = require("zod");
1552
+ var REDUX_TOOL_ID = "redux";
1553
+ var text6 = (s) => ({ content: [{ type: "text", text: s }] });
1554
+ var fail7 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1555
+ var errMsg7 = (e) => e instanceof Error ? e.message : String(e);
1556
+ var NO_STORE = "No instrumented Redux store found. Open the app with Buoy redux devtools active (auto-instrumentation), then retry.";
1557
+ function registerReduxTools(server, sync) {
1558
+ server.registerTool(
1559
+ "get_redux_state",
1560
+ {
1561
+ 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'].)",
1562
+ inputSchema: {
1563
+ includeValues: import_zod7.z.boolean().optional().describe("Include the full current state tree (HEAVY). Default false (slice names only)."),
1564
+ deviceId: import_zod7.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1565
+ }
1566
+ },
1567
+ async (args) => {
1568
+ try {
1569
+ const target = sync.requireProDevice(args.deviceId);
1570
+ const res = await sync.callAction(
1571
+ REDUX_TOOL_ID,
1572
+ "getState",
1573
+ { includeValues: args.includeValues ?? false },
1574
+ target
1575
+ );
1576
+ if (!res) {
1577
+ return fail7(
1578
+ "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)."
1579
+ );
1580
+ }
1581
+ if (!res.available) return text6(NO_STORE);
1582
+ const slices = res.slices.length ? res.slices.join(", ") : "(none)";
1583
+ if (args.includeValues) {
1584
+ return text6(`**Redux store \u2014 slices: ${slices}**
1585
+
1586
+ \`\`\`json
1587
+ ${JSON.stringify(res.state, null, 2)}
1588
+ \`\`\``);
1589
+ }
1590
+ return text6(
1591
+ `**Redux store \u2014 slices: ${slices}**
1592
+
1593
+ _Compact view. Pass includeValues:true for the full state tree. Dispatch with redux_dispatch({type, payload})._`
1594
+ );
1595
+ } catch (e) {
1596
+ return fail7(errMsg7(e));
1597
+ }
1598
+ }
1599
+ );
1600
+ server.registerTool(
1601
+ "redux_dispatch",
1602
+ {
1603
+ 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 }.",
1604
+ inputSchema: {
1605
+ type: import_zod7.z.string().describe('The action type, e.g. "counter/increment" or "auth/logout".'),
1606
+ payload: import_zod7.z.unknown().optional().describe("Optional action payload (any JSON)."),
1607
+ deviceId: import_zod7.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1608
+ }
1609
+ },
1610
+ async (args) => {
1611
+ try {
1612
+ const target = sync.requireProDevice(args.deviceId);
1613
+ const action = { type: args.type };
1614
+ if (args.payload !== void 0) action.payload = args.payload;
1615
+ const res = await sync.callAction(REDUX_TOOL_ID, "dispatch", { action }, target);
1616
+ if (!res || !res.dispatched) return fail7(NO_STORE);
1617
+ return text6(`Dispatched \`${args.type}\`${args.payload !== void 0 ? ` with payload ${JSON.stringify(args.payload)}` : ""}.`);
1618
+ } catch (e) {
1619
+ return fail7(errMsg7(e));
1620
+ }
1621
+ }
1622
+ );
1623
+ }
1624
+
1625
+ // src/tools/packs/storage.ts
1626
+ var import_zod8 = require("zod");
1627
+ var STORAGE_TOOL_ID = "storage";
1628
+ var text7 = (s) => ({ content: [{ type: "text", text: s }] });
1629
+ var fail8 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1630
+ var errMsg8 = (e) => e instanceof Error ? e.message : String(e);
1631
+ function truncate(v, n = 200) {
1632
+ if (v === null || v === void 0) return "(null)";
1633
+ const s = typeof v === "string" ? v : JSON.stringify(v);
1634
+ return s.length > n ? `${s.slice(0, n)}\u2026(+${s.length - n} chars)` : s;
1635
+ }
1636
+ function registerStorageTools(server, sync) {
1637
+ server.registerTool(
1638
+ "get_storage",
1639
+ {
1640
+ 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'.",
1641
+ inputSchema: {
1642
+ pattern: import_zod8.z.string().optional().describe("Only keys containing this substring (case-insensitive)."),
1643
+ includeValues: import_zod8.z.boolean().optional().describe("Include each key's value (truncated). Default false (keys only)."),
1644
+ limit: import_zod8.z.number().optional().describe("Max keys to return. Default 100."),
1645
+ deviceId: import_zod8.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1646
+ }
1647
+ },
1648
+ async (args) => {
1649
+ try {
1650
+ const target = sync.requireProDevice(args.deviceId);
1651
+ const allKeys = await sync.callAction(STORAGE_TOOL_ID, "async.getAllKeys", {}, target);
1652
+ if (!Array.isArray(allKeys)) {
1653
+ return fail8("No storage result from the device. The storage tool may be inactive.");
1654
+ }
1655
+ const pattern = args.pattern?.toLowerCase();
1656
+ const matched = pattern ? allKeys.filter((k) => k.toLowerCase().includes(pattern)) : allKeys;
1657
+ const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 100;
1658
+ const keys = matched.slice(0, limit);
1659
+ if (keys.length === 0) {
1660
+ return text7(`No storage keys${pattern ? ` matching "${args.pattern}"` : ""} (${allKeys.length} total).`);
1661
+ }
1662
+ let lines;
1663
+ if (args.includeValues) {
1664
+ const pairs = await sync.callAction(STORAGE_TOOL_ID, "async.multiGet", { keys }, target);
1665
+ const map = new Map((pairs ?? []).map(([k, v]) => [k, v]));
1666
+ lines = keys.map((k) => `- \`${k}\` = ${truncate(map.get(k) ?? null)}`);
1667
+ } else {
1668
+ lines = keys.map((k) => `- \`${k}\``);
1669
+ }
1670
+ const filteredNote = pattern ? ` matching "${args.pattern}"` : "";
1671
+ const header = `**Storage \u2014 ${allKeys.length} keys (showing ${keys.length}${filteredNote}):**`;
1672
+ const note = args.includeValues ? "" : "\n\n_Keys only. Pass includeValues:true for values, or a pattern to filter. Modify with storage_action._";
1673
+ return text7(`${header}
1674
+
1675
+ ${lines.join("\n")}${note}`);
1676
+ } catch (e) {
1677
+ return fail8(errMsg8(e));
1678
+ }
1679
+ }
1680
+ );
1681
+ server.registerTool(
1682
+ "storage_action",
1683
+ {
1684
+ 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.",
1685
+ inputSchema: {
1686
+ action: import_zod8.z.enum(["set", "remove", "clear"]).describe("set/remove a key, or clear all app storage."),
1687
+ key: import_zod8.z.string().optional().describe("The key (required for set and remove)."),
1688
+ value: import_zod8.z.string().optional().describe("The string value to store (required for set; JSON-encode objects)."),
1689
+ deviceId: import_zod8.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1690
+ }
1691
+ },
1692
+ async (args) => {
1693
+ try {
1694
+ const target = sync.requireProDevice(args.deviceId);
1695
+ if (args.action === "clear") {
1696
+ await sync.callAction(STORAGE_TOOL_ID, "clearAppStorage", {}, target);
1697
+ return text7("Cleared all app storage (Buoy's own devtool keys preserved).");
1698
+ }
1699
+ if (!args.key) {
1700
+ return fail8(`'${args.action}' needs a key.`);
1701
+ }
1702
+ if (args.action === "remove") {
1703
+ await sync.callAction(STORAGE_TOOL_ID, "async.removeItem", { key: args.key }, target);
1704
+ return text7(`Removed storage key "${args.key}".`);
1705
+ }
1706
+ if (args.value === void 0) {
1707
+ return fail8("'set' needs a value (a string).");
1708
+ }
1709
+ await sync.callAction(STORAGE_TOOL_ID, "async.setItem", { key: args.key, value: args.value }, target);
1710
+ return text7(`Set storage key "${args.key}".`);
1711
+ } catch (e) {
1712
+ return fail8(errMsg8(e));
1713
+ }
1714
+ }
1715
+ );
1716
+ }
1717
+
1718
+ // src/tools/packs/routes.ts
1719
+ var import_zod9 = require("zod");
1720
+ var ROUTES_TOOL_ID = "route-events";
1721
+ var text8 = (s) => ({ content: [{ type: "text", text: s }] });
1722
+ var fail9 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1723
+ var errMsg9 = (e) => e instanceof Error ? e.message : String(e);
1724
+ function flattenRoutes(routes, acc = []) {
1725
+ for (const r of routes ?? []) {
1726
+ if (r.path) acc.push(r.path);
1727
+ if (r.children?.length) flattenRoutes(r.children, acc);
1728
+ }
1729
+ return acc;
1730
+ }
1731
+ function registerRoutesTools(server, sync) {
1732
+ server.registerTool(
1733
+ "get_routes",
1734
+ {
1735
+ 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'].)",
1736
+ inputSchema: {
1737
+ includeSitemap: import_zod9.z.boolean().optional().describe("Also list all defined route paths (can be long). Default false."),
1738
+ deviceId: import_zod9.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1739
+ }
1740
+ },
1741
+ async (args) => {
1742
+ try {
1743
+ const target = sync.requireProDevice(args.deviceId);
1744
+ const { payload } = await sync.getSnapshot(ROUTES_TOOL_ID, target);
1745
+ const p = Array.isArray(payload) ? { events: payload } : payload ?? {};
1746
+ const stack = Array.isArray(p.stack) ? p.stack : [];
1747
+ const focused = stack.find((s) => s.isFocused) ?? stack[stack.length - 1];
1748
+ const current = focused?.pathname ?? "(unknown)";
1749
+ const parts = [`**Current route:** \`${current}\``];
1750
+ if (stack.length) {
1751
+ const stackLines = stack.map(
1752
+ (s, i) => `${i + 1}. \`${s.pathname ?? s.name ?? "?"}\`${s.isFocused ? " \u2190 focused" : ""}`
1753
+ );
1754
+ parts.push(`
1755
+ **Navigation stack** (top last):
1756
+ ${stackLines.join("\n")}`);
1757
+ }
1758
+ const allPaths = flattenRoutes(p.sitemap?.routes);
1759
+ if (args.includeSitemap) {
1760
+ const shown = allPaths.slice(0, 200);
1761
+ parts.push(
1762
+ `
1763
+ **Sitemap (${allPaths.length} routes${shown.length < allPaths.length ? `, showing ${shown.length}` : ""}):**
1764
+ ${shown.map((r) => `- \`${r}\``).join("\n")}`
1765
+ );
1766
+ } else {
1767
+ parts.push(`
1768
+ **Sitemap:** ${allPaths.length} routes _(pass includeSitemap:true to list them)_`);
1769
+ }
1770
+ parts.push("\n_Navigate with navigate({ path })._");
1771
+ return text8(parts.join("\n"));
1772
+ } catch (e) {
1773
+ return fail9(errMsg9(e));
1774
+ }
1775
+ }
1776
+ );
1777
+ server.registerTool(
1778
+ "navigate",
1779
+ {
1780
+ 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.",
1781
+ inputSchema: {
1782
+ path: import_zod9.z.string().describe("The concrete path to navigate to, e.g. '/pokemon/25'."),
1783
+ deviceId: import_zod9.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1784
+ }
1785
+ },
1786
+ async (args) => {
1787
+ try {
1788
+ const target = sync.requireProDevice(args.deviceId);
1789
+ await sync.callAction(ROUTES_TOOL_ID, "navigate", { path: args.path }, target);
1790
+ return text8(`Navigated to \`${args.path}\`.`);
1791
+ } catch (e) {
1792
+ return fail9(errMsg9(e));
1793
+ }
1794
+ }
1795
+ );
1796
+ }
1797
+
1798
+ // src/tools/packs/console.ts
1799
+ var import_zod10 = require("zod");
1800
+ var CONSOLE_TOOL_ID = "console";
1801
+ var text9 = (s) => ({ content: [{ type: "text", text: s }] });
1802
+ var fail10 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1803
+ var errMsg10 = (e) => e instanceof Error ? e.message : String(e);
1804
+ var SEVERITY = { verbose: 0, info: 1, warning: 2, error: 3 };
1805
+ var TAG = { verbose: "\xB7", info: "info", warning: "warn", error: "ERR" };
1806
+ function hhmmss(ts) {
1807
+ const d = new Date(ts);
1808
+ const p = (n, l = 2) => String(n).padStart(l, "0");
1809
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
1810
+ }
1811
+ function registerConsoleTools(server, sync) {
1812
+ server.registerTool(
1813
+ "get_console",
1814
+ {
1815
+ 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').",
1816
+ inputSchema: {
1817
+ level: import_zod10.z.enum(["verbose", "info", "warning", "error"]).optional().describe("Minimum level to include (severity threshold). e.g. 'warning' = warnings + errors. Default all."),
1818
+ limit: import_zod10.z.number().optional().describe("Most-recent N entries. Default 50."),
1819
+ pattern: import_zod10.z.string().optional().describe("Only entries whose message contains this substring (case-insensitive)."),
1820
+ deviceId: import_zod10.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1821
+ }
1822
+ },
1823
+ async (args) => {
1824
+ try {
1825
+ const target = sync.requireProDevice(args.deviceId);
1826
+ const { payload } = await sync.getSnapshot(CONSOLE_TOOL_ID, target);
1827
+ const p = Array.isArray(payload) ? { entries: payload } : payload ?? {};
1828
+ const all = Array.isArray(p.entries) ? p.entries : [];
1829
+ const minSev = args.level ? SEVERITY[args.level] : 0;
1830
+ const pattern = args.pattern?.toLowerCase();
1831
+ const filtered = all.filter(
1832
+ (e) => SEVERITY[e.level] >= minSev && (!pattern || (e.message ?? "").toLowerCase().includes(pattern))
1833
+ );
1834
+ const sorted = [...filtered].sort((a, b) => a.id - b.id);
1835
+ const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 50;
1836
+ const shown = sorted.slice(-limit);
1837
+ if (shown.length === 0) {
1838
+ return text9(`No console entries${args.level ? ` at level \u2265 ${args.level}` : ""}${pattern ? ` matching "${args.pattern}"` : ""} (${all.length} total captured).`);
1839
+ }
1840
+ const lines = shown.map((e) => `${hhmmss(e.timestamp)} [${TAG[e.level] ?? e.level}] ${e.message}`);
1841
+ const levelNote = args.level ? `, level \u2265 ${args.level}` : "";
1842
+ const header = `**Console \u2014 ${all.length} entries (showing ${shown.length}${levelNote}):**`;
1843
+ return text9(`${header}
1844
+
1845
+ \`\`\`
1846
+ ${lines.join("\n")}
1847
+ \`\`\`
1848
+
1849
+ _Tail of recent logs. Use get_snapshot('console') for full args/stacks._`);
1850
+ } catch (e) {
1851
+ return fail10(errMsg10(e));
1852
+ }
1853
+ }
1854
+ );
1855
+ }
1856
+
1857
+ // src/tools/packs/screenshot.ts
1858
+ var import_zod11 = require("zod");
1859
+
1860
+ // src/capture/simctl.ts
1861
+ var import_node_child_process = require("child_process");
1862
+ var import_node_util = require("util");
1863
+ var import_node_fs2 = require("fs");
1864
+ var import_node_os2 = __toESM(require("os"));
1865
+ var import_node_path2 = __toESM(require("path"));
1866
+ var import_node_crypto2 = require("crypto");
1867
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
1868
+ var clamp2 = (n, lo, hi) => Math.min(Math.max(n, lo), hi);
1869
+ function humanizeSimctlError(err) {
1870
+ const e = err;
1871
+ if (e?.code === "ENOENT") {
1872
+ return "Xcode command-line tools (xcrun) not found. Install Xcode to capture the iOS Simulator.";
1873
+ }
1874
+ const msg = String(e?.stderr || e?.message || err);
1875
+ if (/No devices are booted|Unable to find device|current device/i.test(msg)) {
1876
+ return "No booted iOS Simulator found. Boot the Simulator with your app running, then try again.";
1877
+ }
1878
+ return msg.trim() || "Failed to capture the iOS Simulator.";
1879
+ }
1880
+ function pngSize(buf) {
1881
+ const SIG = [137, 80, 78, 71, 13, 10, 26, 10];
1882
+ if (buf.length < 24 || !SIG.every((b, i) => buf[i] === b)) return null;
1883
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
1884
+ }
1885
+ async function captureIosFramebuffer() {
1886
+ if (process.platform !== "darwin") {
1887
+ return {
1888
+ ok: false,
1889
+ error: "Screenshotting a component requires macOS with Xcode's iOS Simulator (uses `xcrun simctl`)."
1890
+ };
1891
+ }
1892
+ const out = import_node_path2.default.join(import_node_os2.default.tmpdir(), `buoy-mcp-sim-${(0, import_node_crypto2.randomUUID)()}.png`);
1893
+ try {
1894
+ await execFileAsync("xcrun", ["simctl", "io", "booted", "screenshot", "--type=png", out], {
1895
+ timeout: 15e3
1896
+ });
1897
+ } catch (err) {
1898
+ return { ok: false, error: humanizeSimctlError(err) };
1899
+ }
1900
+ try {
1901
+ const buf = await import_node_fs2.promises.readFile(out);
1902
+ const size = pngSize(buf);
1903
+ if (!size) return { ok: false, error: "Captured file wasn't a valid PNG." };
1904
+ return { ok: true, srcPath: out, width: size.width, height: size.height };
1905
+ } catch (err) {
1906
+ return { ok: false, error: `Failed to read screenshot: ${err.message}` };
1907
+ }
1908
+ }
1909
+ async function cropToBuffer(srcPath, full, region) {
1910
+ const x = clamp2(Math.round(region.x), 0, Math.max(0, full.width - 1));
1911
+ const y = clamp2(Math.round(region.y), 0, Math.max(0, full.height - 1));
1912
+ const width = clamp2(Math.round(region.width), 1, full.width - x);
1913
+ const height = clamp2(Math.round(region.height), 1, full.height - y);
1914
+ const out = import_node_path2.default.join(import_node_os2.default.tmpdir(), `buoy-mcp-crop-${(0, import_node_crypto2.randomUUID)()}.png`);
1915
+ try {
1916
+ await execFileAsync(
1917
+ "sips",
1918
+ ["-c", String(height), String(width), "--cropOffset", String(y), String(x), srcPath, "--out", out],
1919
+ { timeout: 1e4 }
1920
+ );
1921
+ const buf = await import_node_fs2.promises.readFile(out);
1922
+ return { ok: true, buffer: buf, width, height };
1923
+ } catch (err) {
1924
+ const e = err;
1925
+ return { ok: false, error: `Crop failed: ${e?.stderr || e?.message || err}` };
1926
+ } finally {
1927
+ await import_node_fs2.promises.unlink(out).catch(() => {
1928
+ });
1929
+ await import_node_fs2.promises.unlink(srcPath).catch(() => {
1930
+ });
1931
+ }
1932
+ }
1933
+
1934
+ // src/tools/packs/screenshot.ts
1935
+ var TOOL_ID = "highlight-updates";
1936
+ var MARGIN_PT = 12;
1937
+ var CAPTURE_PADDING_PT = 6;
1938
+ var fail11 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
1939
+ var errMsg11 = (e) => e instanceof Error ? e.message : String(e);
1940
+ function reasonMessage(res, query) {
1941
+ switch (res.reason) {
1942
+ case "no-renders":
1943
+ 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.";
1944
+ case "no-match":
1945
+ return query ? `Nothing matched "${query}".` : "No query or nativeTag given \u2014 pass one, or a testID/name to search for.";
1946
+ case "no-measurement":
1947
+ return "Found the component but couldn't measure it \u2014 it may have just unmounted. Re-navigate to it and retry.";
1948
+ default:
1949
+ return res.reason ? `Locate failed: ${res.reason}` : "Couldn't locate that component.";
1950
+ }
1951
+ }
1952
+ function candidatesText(candidates) {
1953
+ if (!candidates || candidates.length === 0) return "";
1954
+ const lines = candidates.map((c) => {
1955
+ const id = c.testID || c.nativeID;
1956
+ return `- ${c.componentName || c.viewType}${id ? ` (${id})` : ""} \u2014 nativeTag ${c.nativeTag}, rendered \xD7${c.renderCount}`;
1957
+ });
1958
+ return `
1959
+
1960
+ Closest matches (retry with a more precise query, or pass nativeTag directly):
1961
+ ${lines.join("\n")}`;
1962
+ }
1963
+ function registerScreenshotTools(server, sync) {
1964
+ server.registerTool(
1965
+ "screenshot_component",
1966
+ {
1967
+ 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.",
1968
+ inputSchema: {
1969
+ query: import_zod11.z.string().optional().describe('Fuzzy match against testID, nativeID, accessibilityLabel, component name, or view type \u2014 e.g. "submit-button".'),
1970
+ nativeTag: import_zod11.z.number().optional().describe("An exact nativeTag (from a previous call's candidates) to skip fuzzy matching."),
1971
+ scrollIntoView: import_zod11.z.boolean().optional().describe("Scroll the component into view before capturing, if it's off-screen. Default true."),
1972
+ deviceId: import_zod11.z.string().optional().describe("Target device id (auto-selected if one is connected).")
1973
+ }
1974
+ },
1975
+ async (args) => {
1976
+ try {
1977
+ const target = sync.requireProDevice(args.deviceId);
1978
+ await sync.callAction(TOOL_ID, "setSilentTracking", { enabled: true }, target).catch(() => {
1979
+ });
1980
+ const res = await sync.callAction(
1981
+ TOOL_ID,
1982
+ "locateComponent",
1983
+ {
1984
+ query: args.query,
1985
+ nativeTag: args.nativeTag,
1986
+ scrollIntoView: args.scrollIntoView ?? true,
1987
+ margin: MARGIN_PT
1988
+ },
1989
+ target
1990
+ );
1991
+ if (!res.matched || !res.rect || !res.screen) {
1992
+ return fail11(reasonMessage(res, args.query) + candidatesText(res.candidates));
1993
+ }
1994
+ const shot = await captureIosFramebuffer();
1995
+ if (!shot.ok || !shot.srcPath || !shot.width || !shot.height) {
1996
+ return fail11(shot.error ?? "Screenshot capture failed.");
1997
+ }
1998
+ const pxScale = shot.width / res.screen.width;
1999
+ const r = res.rect;
2000
+ const pad = CAPTURE_PADDING_PT;
2001
+ const crop = await cropToBuffer(
2002
+ shot.srcPath,
2003
+ { width: shot.width, height: shot.height },
2004
+ {
2005
+ x: (r.x - pad) * pxScale,
2006
+ y: (r.y - pad) * pxScale,
2007
+ width: (r.width + pad * 2) * pxScale,
2008
+ height: (r.height + pad * 2) * pxScale
2009
+ }
2010
+ );
2011
+ if (!crop.ok || !crop.buffer) {
2012
+ return fail11(crop.error ?? "Crop failed.");
2013
+ }
2014
+ const label = res.componentName || "component";
2015
+ const idPart = res.testID ? ` (testID: "${res.testID}")` : "";
2016
+ 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." : "";
2017
+ const summary = `Captured "${label}"${idPart} \u2014 ${crop.width}\xD7${crop.height}px.${warn}`;
2018
+ return {
2019
+ content: [
2020
+ { type: "image", data: crop.buffer.toString("base64"), mimeType: "image/png" },
2021
+ { type: "text", text: summary }
2022
+ ]
2023
+ };
2024
+ } catch (e) {
2025
+ return fail11(errMsg11(e));
2026
+ }
2027
+ }
2028
+ );
2029
+ }
2030
+
2031
+ // src/tools/packs/interact.ts
2032
+ var import_zod12 = require("zod");
2033
+ var TOOL_ID2 = "highlight-updates";
2034
+ var text10 = (s) => ({ content: [{ type: "text", text: s }] });
2035
+ var fail12 = (message) => ({
2036
+ isError: true,
2037
+ content: [{ type: "text", text: message }]
2038
+ });
2039
+ var errMsg12 = (e) => e instanceof Error ? e.message : String(e);
2040
+ function formatElement(e) {
2041
+ let line = e.interactive ? "\u2022 " : " ";
2042
+ line += e.name;
2043
+ const display = e.text ?? e.label;
2044
+ if (display) line += ` "${display}"`;
2045
+ if (e.role) line += ` <${e.role}>`;
2046
+ if (e.control) line += ` {${e.control}${e.value !== void 0 ? `=${JSON.stringify(e.value)}` : ""}}`;
2047
+ if (e.testID) line += ` [testID=${e.testID}]`;
2048
+ if (e.tap) line += ` (tap: ${e.tap.x},${e.tap.y})`;
2049
+ if (e.nativeTag != null) line += ` #${e.nativeTag}`;
2050
+ return line;
2051
+ }
2052
+ function registerInteractTools(server, sync) {
2053
+ server.registerTool(
2054
+ "describe_screen",
2055
+ {
2056
+ 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.",
2057
+ inputSchema: {
2058
+ interactiveOnly: import_zod12.z.boolean().optional().describe("Only return tappable (onPress) elements. Default false (include labels/text for context)."),
2059
+ deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2060
+ }
2061
+ },
2062
+ async (args) => {
2063
+ try {
2064
+ const target = sync.requireProDevice(args.deviceId);
2065
+ const res = await sync.callAction(TOOL_ID2, "describeScreen", {}, target);
2066
+ const all = res?.elements ?? [];
2067
+ const elements = args.interactiveOnly ? all.filter((e) => e.interactive) : all;
2068
+ if (elements.length === 0) {
2069
+ return text10(
2070
+ "No elements found on screen. If the app just navigated, wait a moment and retry; make sure highlight-updates is enabled in the app."
2071
+ );
2072
+ }
2073
+ 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 }).`;
2074
+ return text10(`${header}
2075
+
2076
+ ${elements.map(formatElement).join("\n")}`);
2077
+ } catch (e) {
2078
+ return fail12(errMsg12(e));
2079
+ }
2080
+ }
2081
+ );
2082
+ server.registerTool(
2083
+ "tap_element",
2084
+ {
2085
+ 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.",
2086
+ inputSchema: {
2087
+ nativeTag: import_zod12.z.number().optional().describe("Exact nativeTag from describe_screen (most precise)."),
2088
+ testID: import_zod12.z.string().optional().describe("Exact testID of the element."),
2089
+ query: import_zod12.z.string().optional().describe('Fuzzy match against testID / label / visible text / component name \u2014 e.g. "Sign in".'),
2090
+ value: import_zod12.z.union([import_zod12.z.number(), import_zod12.z.boolean()]).optional().describe("For a toggle: true/false (omit to flip). For a slider: the numeric value (omit to jump to the far end)."),
2091
+ text: import_zod12.z.string().optional().describe("For a text input: the string to set via onChangeText."),
2092
+ scrollIntoView: import_zod12.z.boolean().optional().describe("Scroll the target into view first if it's off-screen. Default true."),
2093
+ deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2094
+ }
2095
+ },
2096
+ async (args) => {
2097
+ try {
2098
+ if (args.nativeTag == null && !args.testID && !args.query) {
2099
+ return fail12("Provide one of: nativeTag, testID, or query (from describe_screen).");
2100
+ }
2101
+ const target = sync.requireProDevice(args.deviceId);
2102
+ const res = await sync.callAction(
2103
+ TOOL_ID2,
2104
+ "tapElement",
2105
+ {
2106
+ nativeTag: args.nativeTag,
2107
+ testID: args.testID,
2108
+ query: args.query,
2109
+ value: args.value,
2110
+ text: args.text,
2111
+ scrollIntoView: args.scrollIntoView
2112
+ },
2113
+ target
2114
+ );
2115
+ if (res?.tapped && res.matched) {
2116
+ const m = res.matched;
2117
+ const label = m.text ?? m.label ?? m.name;
2118
+ const id = m.testID ? ` [testID=${m.testID}]` : m.nativeTag != null ? ` #${m.nativeTag}` : "";
2119
+ const scrolledNote = res.scrolled ? " (scrolled into view)" : "";
2120
+ 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)" : ""}`;
2121
+ return text10(`${verb}${scrolledNote}.`);
2122
+ }
2123
+ let msg = res?.reason ?? "Tap failed.";
2124
+ if (res?.candidates?.length) {
2125
+ 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");
2126
+ }
2127
+ return fail12(msg);
2128
+ } catch (e) {
2129
+ return fail12(errMsg12(e));
2130
+ }
2131
+ }
2132
+ );
2133
+ }
2134
+
1150
2135
  // src/server.ts
1151
2136
  var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
1152
2137
 
1153
2138
  Buoy tools live inside the app and sync to a local broker; this server connects to that
1154
2139
  broker as a dashboard client. Start by calling \`list_devices\` to see which device(s) are
1155
- connected and which tools (e.g. "perf-monitor", "network", "react-query") they expose, then:
2140
+ connected and which tools (e.g. "perf-monitor", "network", "react-query") they expose.
2141
+
2142
+ To inspect what's HAPPENING at runtime \u2014 network requests, state changes (redux/zustand/jotai),
2143
+ react-query updates, storage writes, route changes, renders \u2014 use \`get_events\` with a \`sources\`
2144
+ filter (e.g. sources:["network"] to list HTTP requests). It's the universal, token-bounded
2145
+ activity reader across ALL tools and is compact by default \u2014 reach for it before reading source
2146
+ code, and prefer it over \`get_snapshot\` for any "what happened / show me the requests" question.
2147
+
2148
+ Need to SEE a component (verify a UI change, check styling/layout)? Use \`screenshot_component\` with
2149
+ a testID/name \u2014 it locates the live component on-device and returns a cropped screenshot inline.
2150
+ iOS Simulator only, and only works when this MCP server runs on the same Mac as the booted Simulator.
2151
+
2152
+ Need to DRIVE the UI (tap a button, follow a flow)? Use \`describe_screen\` to list the on-screen
2153
+ elements (accessibility-style tree with tap points + testIDs, no screenshot), then \`tap_element\`
2154
+ ({ testID | query | nativeTag }) to tap one \u2014 it invokes the element's onPress directly in JS, so
2155
+ it's fast and works on physical devices too. Re-run \`describe_screen\` after the screen changes.
2156
+
2157
+ For everything else:
1156
2158
  - \`list_tool_actions\` to see what a tool can do,
1157
- - \`get_snapshot\` to read a tool's current state,
2159
+ - \`get_snapshot\` ONLY when you need a tool's full current state (returns raw, heavy payloads),
1158
2160
  - \`call_action\` to invoke any tool action generically.
1159
2161
 
1160
2162
  Higher-level tools (e.g. perf-monitor's \`run_benchmark_batch\`) wrap these for common workflows.
@@ -1165,18 +2167,109 @@ require the connected app to have an active Buoy Pro license. If they error with
1165
2167
  required", tell the user to upgrade to Buoy Pro in the app \u2014 don't try to work around it.`;
1166
2168
  function createMcpServer(sync) {
1167
2169
  const server = new import_mcp.McpServer(
1168
- { name: "buoy", version: "3.0.2" },
2170
+ { name: "buoy", version: VERSION },
1169
2171
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
1170
2172
  );
1171
2173
  registerGenericTools(server, sync);
1172
2174
  registerPerfMonitorTools(server, sync);
2175
+ registerEventsTools(server, sync);
2176
+ registerReactQueryTools(server, sync);
2177
+ registerZustandTools(server, sync);
2178
+ registerJotaiTools(server, sync);
2179
+ registerReduxTools(server, sync);
2180
+ registerStorageTools(server, sync);
2181
+ registerRoutesTools(server, sync);
2182
+ registerConsoleTools(server, sync);
2183
+ registerScreenshotTools(server, sync);
2184
+ registerInteractTools(server, sync);
1173
2185
  return server;
1174
2186
  }
1175
2187
  var deviceIdField = {
1176
- deviceId: import_zod3.z.string().optional().describe(
2188
+ deviceId: import_zod13.z.string().optional().describe(
1177
2189
  'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
1178
2190
  )
1179
2191
  };
2192
+
2193
+ // src/init.ts
2194
+ var import_node_fs3 = __toESM(require("fs"));
2195
+ var import_node_path3 = __toESM(require("path"));
2196
+ function runInit(opts = {}) {
2197
+ const cwd = opts.cwd ?? process.cwd();
2198
+ const skillSrcDir = opts.skillSrcDir ?? import_node_path3.default.resolve(__dirname, "../skill");
2199
+ const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
2200
+ const entry = opts.cliPath ? { type: "stdio", command: "node", args: [opts.cliPath] } : { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
2201
+ const done = [];
2202
+ upsertMcpConfig(import_node_path3.default.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
2203
+ if (import_node_fs3.default.existsSync(import_node_path3.default.join(cwd, ".cursor"))) {
2204
+ upsertMcpConfig(import_node_path3.default.join(cwd, ".cursor", "mcp.json"), "mcpServers", entry, cwd, done);
2205
+ }
2206
+ if (import_node_fs3.default.existsSync(import_node_path3.default.join(cwd, ".vscode"))) {
2207
+ upsertMcpConfig(import_node_path3.default.join(cwd, ".vscode", "mcp.json"), "servers", entry, cwd, done);
2208
+ }
2209
+ installSkills(skillSrcDir, cwd, done);
2210
+ log("Buoy MCP installed:");
2211
+ for (const line of done) log(" \u2022 " + line);
2212
+ log("");
2213
+ log("Next steps:");
2214
+ log(" 1. Restart your editor (or reconnect the MCP server) to load `buoy`.");
2215
+ log(" 2. Open your app with Buoy devtools running on a device or simulator.");
2216
+ log(' 3. Say "buoy optimize" to start a guided performance pass.');
2217
+ if (!opts.cliPath) {
2218
+ log("");
2219
+ log(`This launches the server via \`npx -y ${PACKAGE_NAME}@latest\`, so it stays`);
2220
+ log(`up to date automatically. To force-refresh, re-run \`npx -y ${PACKAGE_NAME}@latest init\`.`);
2221
+ }
2222
+ }
2223
+ function upsertMcpConfig(file, key, entry, cwd, done) {
2224
+ let json3 = {};
2225
+ if (import_node_fs3.default.existsSync(file)) {
2226
+ try {
2227
+ json3 = JSON.parse(import_node_fs3.default.readFileSync(file, "utf8"));
2228
+ } catch {
2229
+ import_node_fs3.default.copyFileSync(file, file + ".bak");
2230
+ done.push(`existing ${rel(file, cwd)} was invalid JSON \u2014 backed up to ${rel(file, cwd)}.bak`);
2231
+ json3 = {};
2232
+ }
2233
+ } else {
2234
+ import_node_fs3.default.mkdirSync(import_node_path3.default.dirname(file), { recursive: true });
2235
+ }
2236
+ const servers = json3[key] && typeof json3[key] === "object" ? json3[key] : {};
2237
+ const existed = "buoy" in servers;
2238
+ servers.buoy = entry;
2239
+ json3[key] = servers;
2240
+ import_node_fs3.default.writeFileSync(file, JSON.stringify(json3, null, 2) + "\n");
2241
+ done.push(`${existed ? "updated" : "added"} \`buoy\` in ${rel(file, cwd)}`);
2242
+ }
2243
+ function installSkills(skillSrcDir, cwd, done) {
2244
+ if (!import_node_fs3.default.existsSync(skillSrcDir)) {
2245
+ done.push("\u26A0 no bundled skills found \u2014 skipped");
2246
+ return;
2247
+ }
2248
+ const skills = import_node_fs3.default.readdirSync(skillSrcDir, { withFileTypes: true }).filter((e) => e.isDirectory() && import_node_fs3.default.existsSync(import_node_path3.default.join(skillSrcDir, e.name, "SKILL.md")));
2249
+ if (skills.length === 0) {
2250
+ done.push("\u26A0 no skills with a SKILL.md found \u2014 skipped");
2251
+ return;
2252
+ }
2253
+ for (const skill of skills) {
2254
+ const from = import_node_path3.default.join(skillSrcDir, skill.name);
2255
+ const to = import_node_path3.default.join(cwd, ".claude", "skills", skill.name);
2256
+ copyDir(from, to);
2257
+ done.push(`installed the ${skill.name} skill \u2192 ${rel(import_node_path3.default.join(to, "SKILL.md"), cwd)}`);
2258
+ }
2259
+ }
2260
+ function copyDir(from, to) {
2261
+ import_node_fs3.default.mkdirSync(to, { recursive: true });
2262
+ for (const entry of import_node_fs3.default.readdirSync(from, { withFileTypes: true })) {
2263
+ const s = import_node_path3.default.join(from, entry.name);
2264
+ const d = import_node_path3.default.join(to, entry.name);
2265
+ if (entry.isDirectory()) copyDir(s, d);
2266
+ else import_node_fs3.default.copyFileSync(s, d);
2267
+ }
2268
+ }
2269
+ function rel(file, cwd) {
2270
+ const r = import_node_path3.default.relative(cwd, file);
2271
+ return r && !r.startsWith("..") ? r : file;
2272
+ }
1180
2273
  // Annotate the CommonJS export names for ESM import in node:
1181
2274
  0 && (module.exports = {
1182
2275
  ALL_DEVICES,
@@ -1185,13 +2278,26 @@ var deviceIdField = {
1185
2278
  DASHBOARD_NAME,
1186
2279
  DEFAULT_ACTION_TIMEOUT_MS,
1187
2280
  DEFAULT_SNAPSHOT_TIMEOUT_MS,
2281
+ PACKAGE_NAME,
1188
2282
  SyncClient,
2283
+ VERSION,
1189
2284
  buildAutomationConfig,
1190
2285
  compareReportStats,
1191
2286
  createMcpServer,
1192
2287
  estimateBatchMs,
1193
2288
  formatRankingMarkdown,
2289
+ getUpdateNotice,
1194
2290
  rankBatchFromIndex,
2291
+ registerConsoleTools,
2292
+ registerEventsTools,
1195
2293
  registerGenericTools,
1196
- registerPerfMonitorTools
2294
+ registerJotaiTools,
2295
+ registerPerfMonitorTools,
2296
+ registerReactQueryTools,
2297
+ registerReduxTools,
2298
+ registerRoutesTools,
2299
+ registerScreenshotTools,
2300
+ registerStorageTools,
2301
+ registerZustandTools,
2302
+ runInit
1197
2303
  });