@buoy-gg/mcp 7.0.22 → 7.0.24

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.
@@ -196,6 +196,7 @@ var SyncClient = class {
196
196
  platform: d.platform,
197
197
  isConnected: d.isConnected !== false,
198
198
  isPro: d.isPro === true,
199
+ tier: d.tier ?? (d.isPro === true ? "pro" : "anonymous"),
199
200
  tools: caps?.tools ?? []
200
201
  };
201
202
  });
@@ -245,14 +246,20 @@ var SyncClient = class {
245
246
  * is blocked.
246
247
  */
247
248
  requireProDevice(deviceId) {
248
- 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).`;
249
+ const tierNote = (dev2) => {
250
+ if (!dev2) return "";
251
+ return dev2.tier === "anonymous" ? ` The connected app "${dev2.deviceName}" has no license key at all.` : ` The connected app "${dev2.deviceName}" is on the ${dev2.tier} tier.`;
252
+ };
253
+ const proMsg = (dev2) => `Buoy Pro is required to use the MCP.${tierNote(
254
+ dev2
255
+ )} Upgrade to Buoy Pro in the app to enable MCP access (run \`list_devices\` to check status).`;
249
256
  if (deviceId === ALL_DEVICES) {
250
257
  if (!this.connectedDevices().some((d) => d.isPro)) throw new Error(proMsg());
251
258
  return ALL_DEVICES;
252
259
  }
253
260
  const target = this.resolveDevice(deviceId);
254
261
  const dev = this.connectedDevices().find((d) => d.deviceId === target);
255
- if (!dev?.isPro) throw new Error(proMsg(dev?.deviceName));
262
+ if (!dev?.isPro) throw new Error(proMsg(dev));
256
263
  return target;
257
264
  }
258
265
  // ----------------------------------------------------------------
@@ -565,7 +572,7 @@ async function waitForBroker(url, attempts = 20) {
565
572
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
566
573
 
567
574
  // src/version.ts
568
- var VERSION = "7.0.22" ? "7.0.22" : "0.0.0-dev";
575
+ var VERSION = "7.0.24" ? "7.0.24" : "0.0.0-dev";
569
576
  var PACKAGE_NAME = "@buoy-gg/mcp";
570
577
 
571
578
  // src/update/checkForUpdate.ts
@@ -660,11 +667,13 @@ function registerGenericTools(server, sync) {
660
667
  ...updateNotice ? { updateAvailable: updateNotice } : {}
661
668
  });
662
669
  }
663
- const anyPro = devices.some((d) => d.isConnected && d.isPro);
670
+ const connected = devices.filter((d) => d.isConnected);
671
+ const anyPro = connected.some((d) => d.isPro);
672
+ const anySignedIn = connected.some((d) => d.tier !== "anonymous");
664
673
  return json({
665
674
  devices,
666
675
  ...anyPro ? {} : {
667
- 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."
676
+ hint: anySignedIn ? "The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them." : "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."
668
677
  },
669
678
  ...updateNotice ? { updateAvailable: updateNotice } : {}
670
679
  });
@@ -737,6 +746,14 @@ function errMsg(e) {
737
746
 
738
747
  // src/tools/packs/perfRanking.ts
739
748
  var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
749
+ var WARMUP_CASE_NAME = "__batch_warmup__";
750
+ var THERMAL_MARK = {
751
+ nominal: "\u{1F7E2} nominal",
752
+ fair: "\u{1F7E1} fair",
753
+ serious: "\u{1F7E0} serious",
754
+ critical: "\u{1F534} critical",
755
+ unknown: "\u2014"
756
+ };
740
757
  var FALLBACK = {
741
758
  perCaseDurationMs: 5e3,
742
759
  settleMs: 600,
@@ -796,6 +813,7 @@ function buildAutomationConfig(input, base = {}) {
796
813
  runsPerCase,
797
814
  coolDownMs,
798
815
  discardWarmupRuns,
816
+ discardWarmupCase: input.discardWarmupCase ?? base.discardWarmupCase ?? true,
799
817
  shuffleCases,
800
818
  reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
801
819
  captureRenders: input.captureRenders ?? base.captureRenders ?? true
@@ -840,7 +858,9 @@ function computeFlags(r) {
840
858
  function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
841
859
  const entries = index.filter((e) => e.batchId === batchId);
842
860
  const failureByIndex = new Map(failures.map((f) => [f.index, f.reason]));
843
- const indexes = casesOrder ? casesOrder.map((_, i) => i) : Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort((a, b) => a - b);
861
+ const indexes = Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort(
862
+ (a, b) => a - b
863
+ );
844
864
  const medianForCase = (batchIndex) => {
845
865
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex);
846
866
  if (runs.length === 0) return void 0;
@@ -850,7 +870,7 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
850
870
  const rows = indexes.map((batchIndex) => {
851
871
  const median = medianForCase(batchIndex);
852
872
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex).length;
853
- const caseName = casesOrder?.[batchIndex]?.name ?? median?.name ?? `case ${batchIndex + 1}`;
873
+ const caseName = median?.name ?? casesOrder?.[batchIndex]?.name ?? `case ${batchIndex + 1}`;
854
874
  const failureReason = failureByIndex.get(batchIndex) ?? median?.batchFailureReason;
855
875
  const failed = Boolean(failureReason) || !median;
856
876
  const base = {
@@ -869,12 +889,13 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
869
889
  deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
870
890
  deltaUiFps: null,
871
891
  deltaJsFps: null,
892
+ ...median?.thermalState ? { thermalState: median.thermalState } : {},
872
893
  renderCommits: num(median?.renderCommits),
873
894
  renderWasted: median?.renderWasted,
874
895
  topRenderers: median?.topRenderers ?? []
875
896
  };
876
897
  return { ...base, flags: failed ? [] : computeFlags(base) };
877
- });
898
+ }).filter((r) => r.caseName !== WARMUP_CASE_NAME).map((r, i) => ({ ...r, batchIndex: i }));
878
899
  const baseline = rows[0];
879
900
  if (baseline) {
880
901
  for (const r of rows) {
@@ -928,18 +949,33 @@ function formatRankingMarkdown(ranking) {
928
949
  `**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.`
929
950
  );
930
951
  lines.push("");
931
- lines.push("| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank | Flags |");
932
- lines.push("|---|------|-------:|-------:|------:|-------:|--------:|--------:|-------|");
952
+ const anyThermal = ranking.rows.some((r) => r.thermalState);
953
+ const thermalHead = anyThermal ? " Thermal |" : "";
954
+ const thermalRule = anyThermal ? "-------|" : "";
955
+ lines.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${thermalHead} Flags |`);
956
+ lines.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${thermalRule}-------|`);
933
957
  for (const r of ranking.rows) {
958
+ const thermalCell = anyThermal ? ` ${THERMAL_MARK[r.thermalState ?? "unknown"] ?? "\u2014"} |` : "";
934
959
  if (r.failed) {
935
- lines.push(`| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${r.failureReason}) |`);
960
+ lines.push(
961
+ `| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${thermalCell} \u274C FAILED (${r.failureReason}) |`
962
+ );
936
963
  continue;
937
964
  }
938
965
  const flagCell = r.flags.length ? `\u26A0\uFE0F ${r.flags.join("; ")}` : "\u2713 clean";
939
966
  lines.push(
940
967
  `| ${r.batchIndex} | ${r.caseName} | ${f(r.uiFps)}${delta(r.deltaUiFps)} | ${f(r.jsFps)}${delta(
941
968
  r.deltaJsFps
942
- )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} | ${flagCell} |`
969
+ )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} |${thermalCell} ${flagCell} |`
970
+ );
971
+ }
972
+ const throttled = ranking.rows.filter(
973
+ (r) => r.thermalState === "serious" || r.thermalState === "critical"
974
+ );
975
+ if (throttled.length) {
976
+ lines.push("");
977
+ lines.push(
978
+ `**\u{1F525} Thermally throttled: ${throttled.map((r) => r.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`
943
979
  );
944
980
  }
945
981
  lines.push("");
package/dist/cli.js CHANGED
@@ -224,6 +224,7 @@ var SyncClient = class {
224
224
  platform: d.platform,
225
225
  isConnected: d.isConnected !== false,
226
226
  isPro: d.isPro === true,
227
+ tier: d.tier ?? (d.isPro === true ? "pro" : "anonymous"),
227
228
  tools: caps?.tools ?? []
228
229
  };
229
230
  });
@@ -273,14 +274,20 @@ var SyncClient = class {
273
274
  * is blocked.
274
275
  */
275
276
  requireProDevice(deviceId) {
276
- 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).`;
277
+ const tierNote = (dev2) => {
278
+ if (!dev2) return "";
279
+ return dev2.tier === "anonymous" ? ` The connected app "${dev2.deviceName}" has no license key at all.` : ` The connected app "${dev2.deviceName}" is on the ${dev2.tier} tier.`;
280
+ };
281
+ const proMsg = (dev2) => `Buoy Pro is required to use the MCP.${tierNote(
282
+ dev2
283
+ )} Upgrade to Buoy Pro in the app to enable MCP access (run \`list_devices\` to check status).`;
277
284
  if (deviceId === ALL_DEVICES) {
278
285
  if (!this.connectedDevices().some((d) => d.isPro)) throw new Error(proMsg());
279
286
  return ALL_DEVICES;
280
287
  }
281
288
  const target = this.resolveDevice(deviceId);
282
289
  const dev = this.connectedDevices().find((d) => d.deviceId === target);
283
- if (!dev?.isPro) throw new Error(proMsg(dev?.deviceName));
290
+ if (!dev?.isPro) throw new Error(proMsg(dev));
284
291
  return target;
285
292
  }
286
293
  // ----------------------------------------------------------------
@@ -605,7 +612,7 @@ var import_node_os = __toESM(require("os"));
605
612
  var import_node_path = __toESM(require("path"));
606
613
 
607
614
  // src/version.ts
608
- var VERSION = "7.0.22" ? "7.0.22" : "0.0.0-dev";
615
+ var VERSION = "7.0.24" ? "7.0.24" : "0.0.0-dev";
609
616
  var PACKAGE_NAME = "@buoy-gg/mcp";
610
617
 
611
618
  // src/update/checkForUpdate.ts
@@ -696,11 +703,13 @@ function registerGenericTools(server, sync) {
696
703
  ...updateNotice ? { updateAvailable: updateNotice } : {}
697
704
  });
698
705
  }
699
- const anyPro = devices.some((d) => d.isConnected && d.isPro);
706
+ const connected = devices.filter((d) => d.isConnected);
707
+ const anyPro = connected.some((d) => d.isPro);
708
+ const anySignedIn = connected.some((d) => d.tier !== "anonymous");
700
709
  return json({
701
710
  devices,
702
711
  ...anyPro ? {} : {
703
- 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."
712
+ hint: anySignedIn ? "The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them." : "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."
704
713
  },
705
714
  ...updateNotice ? { updateAvailable: updateNotice } : {}
706
715
  });
@@ -779,6 +788,14 @@ var PERF_TOOL_ID = "perf-monitor";
779
788
 
780
789
  // src/tools/packs/perfRanking.ts
781
790
  var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
791
+ var WARMUP_CASE_NAME = "__batch_warmup__";
792
+ var THERMAL_MARK = {
793
+ nominal: "\u{1F7E2} nominal",
794
+ fair: "\u{1F7E1} fair",
795
+ serious: "\u{1F7E0} serious",
796
+ critical: "\u{1F534} critical",
797
+ unknown: "\u2014"
798
+ };
782
799
  var FALLBACK = {
783
800
  perCaseDurationMs: 5e3,
784
801
  settleMs: 600,
@@ -838,6 +855,7 @@ function buildAutomationConfig(input, base = {}) {
838
855
  runsPerCase,
839
856
  coolDownMs,
840
857
  discardWarmupRuns,
858
+ discardWarmupCase: input.discardWarmupCase ?? base.discardWarmupCase ?? true,
841
859
  shuffleCases,
842
860
  reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
843
861
  captureRenders: input.captureRenders ?? base.captureRenders ?? true
@@ -882,7 +900,9 @@ function computeFlags(r) {
882
900
  function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
883
901
  const entries = index.filter((e) => e.batchId === batchId);
884
902
  const failureByIndex = new Map(failures.map((f) => [f.index, f.reason]));
885
- const indexes = casesOrder ? casesOrder.map((_, i) => i) : Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort((a, b) => a - b);
903
+ const indexes = Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort(
904
+ (a, b) => a - b
905
+ );
886
906
  const medianForCase = (batchIndex) => {
887
907
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex);
888
908
  if (runs.length === 0) return void 0;
@@ -892,7 +912,7 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
892
912
  const rows = indexes.map((batchIndex) => {
893
913
  const median = medianForCase(batchIndex);
894
914
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex).length;
895
- const caseName = casesOrder?.[batchIndex]?.name ?? median?.name ?? `case ${batchIndex + 1}`;
915
+ const caseName = median?.name ?? casesOrder?.[batchIndex]?.name ?? `case ${batchIndex + 1}`;
896
916
  const failureReason = failureByIndex.get(batchIndex) ?? median?.batchFailureReason;
897
917
  const failed = Boolean(failureReason) || !median;
898
918
  const base = {
@@ -911,12 +931,13 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
911
931
  deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
912
932
  deltaUiFps: null,
913
933
  deltaJsFps: null,
934
+ ...median?.thermalState ? { thermalState: median.thermalState } : {},
914
935
  renderCommits: num(median?.renderCommits),
915
936
  renderWasted: median?.renderWasted,
916
937
  topRenderers: median?.topRenderers ?? []
917
938
  };
918
939
  return { ...base, flags: failed ? [] : computeFlags(base) };
919
- });
940
+ }).filter((r) => r.caseName !== WARMUP_CASE_NAME).map((r, i) => ({ ...r, batchIndex: i }));
920
941
  const baseline = rows[0];
921
942
  if (baseline) {
922
943
  for (const r of rows) {
@@ -970,18 +991,33 @@ function formatRankingMarkdown(ranking) {
970
991
  `**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.`
971
992
  );
972
993
  lines.push("");
973
- lines.push("| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank | Flags |");
974
- lines.push("|---|------|-------:|-------:|------:|-------:|--------:|--------:|-------|");
994
+ const anyThermal = ranking.rows.some((r) => r.thermalState);
995
+ const thermalHead = anyThermal ? " Thermal |" : "";
996
+ const thermalRule = anyThermal ? "-------|" : "";
997
+ lines.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${thermalHead} Flags |`);
998
+ lines.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${thermalRule}-------|`);
975
999
  for (const r of ranking.rows) {
1000
+ const thermalCell = anyThermal ? ` ${THERMAL_MARK[r.thermalState ?? "unknown"] ?? "\u2014"} |` : "";
976
1001
  if (r.failed) {
977
- lines.push(`| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${r.failureReason}) |`);
1002
+ lines.push(
1003
+ `| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${thermalCell} \u274C FAILED (${r.failureReason}) |`
1004
+ );
978
1005
  continue;
979
1006
  }
980
1007
  const flagCell = r.flags.length ? `\u26A0\uFE0F ${r.flags.join("; ")}` : "\u2713 clean";
981
1008
  lines.push(
982
1009
  `| ${r.batchIndex} | ${r.caseName} | ${f(r.uiFps)}${delta(r.deltaUiFps)} | ${f(r.jsFps)}${delta(
983
1010
  r.deltaJsFps
984
- )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} | ${flagCell} |`
1011
+ )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} |${thermalCell} ${flagCell} |`
1012
+ );
1013
+ }
1014
+ const throttled = ranking.rows.filter(
1015
+ (r) => r.thermalState === "serious" || r.thermalState === "critical"
1016
+ );
1017
+ if (throttled.length) {
1018
+ lines.push("");
1019
+ lines.push(
1020
+ `**\u{1F525} Thermally throttled: ${throttled.map((r) => r.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`
985
1021
  );
986
1022
  }
987
1023
  lines.push("");
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  SyncClient,
4
4
  createMcpServer,
5
5
  runInit
6
- } from "./chunk-5C6BLMHY.mjs";
6
+ } from "./chunk-DIXFOY4A.mjs";
7
7
 
8
8
  // src/cli.ts
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/dist/index.d.mts CHANGED
@@ -24,6 +24,14 @@ interface DeviceInfo {
24
24
  isConnected: boolean;
25
25
  /** Whether the connected app reported an active Buoy Pro license. */
26
26
  isPro: boolean;
27
+ /**
28
+ * The app's access tier. Apps older than the tier split report only `isPro`,
29
+ * which maps exactly — a key could only ever grant Pro back then.
30
+ *
31
+ * Surfaced so `list_devices` can say WHY the MCP is blocked: "signed in on
32
+ * the free tier" and "no account at all" are different problems.
33
+ */
34
+ tier: "anonymous" | "free" | "pro";
27
35
  tools: ToolCapability[];
28
36
  }
29
37
  /** How the client obtained its broker connection. */
@@ -426,6 +434,12 @@ interface AutomationConfig {
426
434
  runsPerCase?: number;
427
435
  coolDownMs?: number;
428
436
  discardWarmupRuns?: number;
437
+ /**
438
+ * Run a throwaway case at the front of the batch and drop it from the
439
+ * results, so the batch's cold start doesn't land on real data. Default true.
440
+ * See the perf-monitor AutomationConfig for the reproduction.
441
+ */
442
+ discardWarmupCase?: boolean;
429
443
  shuffleCases?: boolean;
430
444
  /** Capture per-component render counts/durations each run. Default true. */
431
445
  captureRenders?: boolean;
@@ -453,6 +467,8 @@ interface IndexEntry {
453
467
  caseId?: string;
454
468
  runIndex?: number;
455
469
  isMedianRun?: boolean;
470
+ /** Thermal state at run start; a throttled run reads cheap AND janky. */
471
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
456
472
  /** Render-capture mirror (absent when capture was unavailable/disabled). */
457
473
  renderCommits?: number;
458
474
  renderTotalMs?: number;
@@ -528,12 +544,13 @@ interface BuildConfigInput {
528
544
  coolDownMs?: number;
529
545
  navTimeoutMs?: number;
530
546
  discardWarmupRuns?: number;
547
+ discardWarmupCase?: boolean;
531
548
  shuffleCases?: boolean;
532
549
  reloadBetweenCases?: boolean;
533
550
  captureRenders?: boolean;
534
551
  }
535
552
  /** The settings subset a driver inherits from the device's saved config. */
536
- type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
553
+ type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "discardWarmupCase" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
537
554
  /**
538
555
  * Build + validate the AutomationConfig the device expects. Throws on bad input.
539
556
  *
@@ -566,6 +583,8 @@ interface RankRow {
566
583
  deltaJsFps: number | null;
567
584
  /** At-risk axes for this case (e.g. "UI thread drops frames", "CPU near cap"). */
568
585
  flags: string[];
586
+ /** Thermal state at the median run's start, when the device reported one. */
587
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
569
588
  /** React commits during the median run (null when capture was off/unavailable). */
570
589
  renderCommits: number | null;
571
590
  /** Top components by total render self-time during the median run. */
package/dist/index.d.ts CHANGED
@@ -24,6 +24,14 @@ interface DeviceInfo {
24
24
  isConnected: boolean;
25
25
  /** Whether the connected app reported an active Buoy Pro license. */
26
26
  isPro: boolean;
27
+ /**
28
+ * The app's access tier. Apps older than the tier split report only `isPro`,
29
+ * which maps exactly — a key could only ever grant Pro back then.
30
+ *
31
+ * Surfaced so `list_devices` can say WHY the MCP is blocked: "signed in on
32
+ * the free tier" and "no account at all" are different problems.
33
+ */
34
+ tier: "anonymous" | "free" | "pro";
27
35
  tools: ToolCapability[];
28
36
  }
29
37
  /** How the client obtained its broker connection. */
@@ -426,6 +434,12 @@ interface AutomationConfig {
426
434
  runsPerCase?: number;
427
435
  coolDownMs?: number;
428
436
  discardWarmupRuns?: number;
437
+ /**
438
+ * Run a throwaway case at the front of the batch and drop it from the
439
+ * results, so the batch's cold start doesn't land on real data. Default true.
440
+ * See the perf-monitor AutomationConfig for the reproduction.
441
+ */
442
+ discardWarmupCase?: boolean;
429
443
  shuffleCases?: boolean;
430
444
  /** Capture per-component render counts/durations each run. Default true. */
431
445
  captureRenders?: boolean;
@@ -453,6 +467,8 @@ interface IndexEntry {
453
467
  caseId?: string;
454
468
  runIndex?: number;
455
469
  isMedianRun?: boolean;
470
+ /** Thermal state at run start; a throttled run reads cheap AND janky. */
471
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
456
472
  /** Render-capture mirror (absent when capture was unavailable/disabled). */
457
473
  renderCommits?: number;
458
474
  renderTotalMs?: number;
@@ -528,12 +544,13 @@ interface BuildConfigInput {
528
544
  coolDownMs?: number;
529
545
  navTimeoutMs?: number;
530
546
  discardWarmupRuns?: number;
547
+ discardWarmupCase?: boolean;
531
548
  shuffleCases?: boolean;
532
549
  reloadBetweenCases?: boolean;
533
550
  captureRenders?: boolean;
534
551
  }
535
552
  /** The settings subset a driver inherits from the device's saved config. */
536
- type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
553
+ type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "discardWarmupCase" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
537
554
  /**
538
555
  * Build + validate the AutomationConfig the device expects. Throws on bad input.
539
556
  *
@@ -566,6 +583,8 @@ interface RankRow {
566
583
  deltaJsFps: number | null;
567
584
  /** At-risk axes for this case (e.g. "UI thread drops frames", "CPU near cap"). */
568
585
  flags: string[];
586
+ /** Thermal state at the median run's start, when the device reported one. */
587
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
569
588
  /** React commits during the median run (null when capture was off/unavailable). */
570
589
  renderCommits: number | null;
571
590
  /** Top components by total render self-time during the median run. */
package/dist/index.js CHANGED
@@ -259,6 +259,7 @@ var SyncClient = class {
259
259
  platform: d.platform,
260
260
  isConnected: d.isConnected !== false,
261
261
  isPro: d.isPro === true,
262
+ tier: d.tier ?? (d.isPro === true ? "pro" : "anonymous"),
262
263
  tools: caps?.tools ?? []
263
264
  };
264
265
  });
@@ -308,14 +309,20 @@ var SyncClient = class {
308
309
  * is blocked.
309
310
  */
310
311
  requireProDevice(deviceId) {
311
- 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).`;
312
+ const tierNote = (dev2) => {
313
+ if (!dev2) return "";
314
+ return dev2.tier === "anonymous" ? ` The connected app "${dev2.deviceName}" has no license key at all.` : ` The connected app "${dev2.deviceName}" is on the ${dev2.tier} tier.`;
315
+ };
316
+ const proMsg = (dev2) => `Buoy Pro is required to use the MCP.${tierNote(
317
+ dev2
318
+ )} Upgrade to Buoy Pro in the app to enable MCP access (run \`list_devices\` to check status).`;
312
319
  if (deviceId === ALL_DEVICES) {
313
320
  if (!this.connectedDevices().some((d) => d.isPro)) throw new Error(proMsg());
314
321
  return ALL_DEVICES;
315
322
  }
316
323
  const target = this.resolveDevice(deviceId);
317
324
  const dev = this.connectedDevices().find((d) => d.deviceId === target);
318
- if (!dev?.isPro) throw new Error(proMsg(dev?.deviceName));
325
+ if (!dev?.isPro) throw new Error(proMsg(dev));
319
326
  return target;
320
327
  }
321
328
  // ----------------------------------------------------------------
@@ -640,7 +647,7 @@ var import_node_os = __toESM(require("os"));
640
647
  var import_node_path = __toESM(require("path"));
641
648
 
642
649
  // src/version.ts
643
- var VERSION = "7.0.22" ? "7.0.22" : "0.0.0-dev";
650
+ var VERSION = "7.0.24" ? "7.0.24" : "0.0.0-dev";
644
651
  var PACKAGE_NAME = "@buoy-gg/mcp";
645
652
 
646
653
  // src/update/checkForUpdate.ts
@@ -731,11 +738,13 @@ function registerGenericTools(server, sync) {
731
738
  ...updateNotice ? { updateAvailable: updateNotice } : {}
732
739
  });
733
740
  }
734
- const anyPro = devices.some((d) => d.isConnected && d.isPro);
741
+ const connected = devices.filter((d) => d.isConnected);
742
+ const anyPro = connected.some((d) => d.isPro);
743
+ const anySignedIn = connected.some((d) => d.tier !== "anonymous");
735
744
  return json({
736
745
  devices,
737
746
  ...anyPro ? {} : {
738
- 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."
747
+ hint: anySignedIn ? "The Buoy MCP is a Pro feature \u2014 the connected device is signed in on the free tier, so data/action tools are blocked. Upgrade to Buoy Pro in the app to enable them." : "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."
739
748
  },
740
749
  ...updateNotice ? { updateAvailable: updateNotice } : {}
741
750
  });
@@ -814,6 +823,14 @@ var PERF_TOOL_ID = "perf-monitor";
814
823
 
815
824
  // src/tools/packs/perfRanking.ts
816
825
  var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
826
+ var WARMUP_CASE_NAME = "__batch_warmup__";
827
+ var THERMAL_MARK = {
828
+ nominal: "\u{1F7E2} nominal",
829
+ fair: "\u{1F7E1} fair",
830
+ serious: "\u{1F7E0} serious",
831
+ critical: "\u{1F534} critical",
832
+ unknown: "\u2014"
833
+ };
817
834
  var FALLBACK = {
818
835
  perCaseDurationMs: 5e3,
819
836
  settleMs: 600,
@@ -873,6 +890,7 @@ function buildAutomationConfig(input, base = {}) {
873
890
  runsPerCase,
874
891
  coolDownMs,
875
892
  discardWarmupRuns,
893
+ discardWarmupCase: input.discardWarmupCase ?? base.discardWarmupCase ?? true,
876
894
  shuffleCases,
877
895
  reloadBetweenCases: input.reloadBetweenCases ?? base.reloadBetweenCases ?? false,
878
896
  captureRenders: input.captureRenders ?? base.captureRenders ?? true
@@ -917,7 +935,9 @@ function computeFlags(r) {
917
935
  function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
918
936
  const entries = index.filter((e) => e.batchId === batchId);
919
937
  const failureByIndex = new Map(failures.map((f) => [f.index, f.reason]));
920
- const indexes = casesOrder ? casesOrder.map((_, i) => i) : Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort((a, b) => a - b);
938
+ const indexes = Array.from(new Set(entries.map((e) => e.batchIndex ?? 0))).sort(
939
+ (a, b) => a - b
940
+ );
921
941
  const medianForCase = (batchIndex) => {
922
942
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex);
923
943
  if (runs.length === 0) return void 0;
@@ -927,7 +947,7 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
927
947
  const rows = indexes.map((batchIndex) => {
928
948
  const median = medianForCase(batchIndex);
929
949
  const runs = entries.filter((e) => (e.batchIndex ?? 0) === batchIndex).length;
930
- const caseName = casesOrder?.[batchIndex]?.name ?? median?.name ?? `case ${batchIndex + 1}`;
950
+ const caseName = median?.name ?? casesOrder?.[batchIndex]?.name ?? `case ${batchIndex + 1}`;
931
951
  const failureReason = failureByIndex.get(batchIndex) ?? median?.batchFailureReason;
932
952
  const failed = Boolean(failureReason) || !median;
933
953
  const base = {
@@ -946,12 +966,13 @@ function rankBatchFromIndex(index, batchId, casesOrder, failures = []) {
946
966
  deviceMaxRefreshRate: num(median?.deviceMaxRefreshRate),
947
967
  deltaUiFps: null,
948
968
  deltaJsFps: null,
969
+ ...median?.thermalState ? { thermalState: median.thermalState } : {},
949
970
  renderCommits: num(median?.renderCommits),
950
971
  renderWasted: median?.renderWasted,
951
972
  topRenderers: median?.topRenderers ?? []
952
973
  };
953
974
  return { ...base, flags: failed ? [] : computeFlags(base) };
954
- });
975
+ }).filter((r) => r.caseName !== WARMUP_CASE_NAME).map((r, i) => ({ ...r, batchIndex: i }));
955
976
  const baseline = rows[0];
956
977
  if (baseline) {
957
978
  for (const r of rows) {
@@ -1005,18 +1026,33 @@ function formatRankingMarkdown(ranking) {
1005
1026
  `**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.`
1006
1027
  );
1007
1028
  lines.push("");
1008
- lines.push("| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank | Flags |");
1009
- lines.push("|---|------|-------:|-------:|------:|-------:|--------:|--------:|-------|");
1029
+ const anyThermal = ranking.rows.some((r) => r.thermalState);
1030
+ const thermalHead = anyThermal ? " Thermal |" : "";
1031
+ const thermalRule = anyThermal ? "-------|" : "";
1032
+ lines.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${thermalHead} Flags |`);
1033
+ lines.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${thermalRule}-------|`);
1010
1034
  for (const r of ranking.rows) {
1035
+ const thermalCell = anyThermal ? ` ${THERMAL_MARK[r.thermalState ?? "unknown"] ?? "\u2014"} |` : "";
1011
1036
  if (r.failed) {
1012
- lines.push(`| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u274C FAILED (${r.failureReason}) |`);
1037
+ lines.push(
1038
+ `| ${r.batchIndex} | ${r.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${thermalCell} \u274C FAILED (${r.failureReason}) |`
1039
+ );
1013
1040
  continue;
1014
1041
  }
1015
1042
  const flagCell = r.flags.length ? `\u26A0\uFE0F ${r.flags.join("; ")}` : "\u2713 clean";
1016
1043
  lines.push(
1017
1044
  `| ${r.batchIndex} | ${r.caseName} | ${f(r.uiFps)}${delta(r.deltaUiFps)} | ${f(r.jsFps)}${delta(
1018
1045
  r.deltaJsFps
1019
- )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} | ${flagCell} |`
1046
+ )} | ${f(r.cpu)} | ${f(r.memMb)} | ${f(r.jsJank, 0)} | ${f(r.uiJank, 0)} |${thermalCell} ${flagCell} |`
1047
+ );
1048
+ }
1049
+ const throttled = ranking.rows.filter(
1050
+ (r) => r.thermalState === "serious" || r.thermalState === "critical"
1051
+ );
1052
+ if (throttled.length) {
1053
+ lines.push("");
1054
+ lines.push(
1055
+ `**\u{1F525} Thermally throttled: ${throttled.map((r) => r.caseName).join(", ")}.** A throttled device runs SLOWER at a LOWER cpu%, so these cases can look like the cheapest in the batch while actually delivering fewer frames \u2014 check their UI FPS and jank, and re-run them on a cool device before drawing any conclusion.`
1020
1056
  );
1021
1057
  }
1022
1058
  lines.push("");
package/dist/index.mjs CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  registerStorageTools,
28
28
  registerZustandTools,
29
29
  runInit
30
- } from "./chunk-5C6BLMHY.mjs";
30
+ } from "./chunk-DIXFOY4A.mjs";
31
31
  export {
32
32
  ALL_DEVICES,
33
33
  BROKER_PORT,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@buoy-gg/mcp",
3
3
  "mcpName": "io.github.buoy-gg/mcp",
4
- "version": "7.0.22",
4
+ "version": "7.0.24",
5
5
  "description": "MCP server for Buoy devtools — lets any LLM copilot drive Buoy tools (perf-monitor benchmarks, etc.) over the devtool sync protocol",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -32,7 +32,7 @@
32
32
  "socket.io-client": "^4.8.1",
33
33
  "zod": "^3.25.0",
34
34
  "ws": "^8.18.0",
35
- "@buoy-gg/sync-broker": "^7.0.22"
35
+ "@buoy-gg/sync-broker": "^7.0.24"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^22.0.0",