@buoy-gg/mcp 7.0.44 → 7.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-YQ3KGD5Y.mjs → chunk-GFFMF22U.mjs} +82 -82
- package/dist/cli.js +2 -2
- package/dist/cli.mjs +1 -1
- package/dist/index.d.mts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +89 -89
- package/dist/index.mjs +1 -1
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __copyProps=(e,r,t,n)=>{if(r&&typeof r==="object"||typeof r==="function"){for(let o of __getOwnPropNames(r))if(!__hasOwnProp.call(e,o)&&o!==t)__defProp(e,o,{get:()=>r[o],enumerable:!(n=__getOwnPropDesc(r,o))||n.enumerable})}return e};var __toESM=(e,r,t)=>(t=e!=null?__create(__getProtoOf(e)):{},__copyProps(r||!e||!e.__esModule?__defProp(t,"default",{value:e,enumerable:true}):t,e));var import_stdio=require("@modelcontextprotocol/sdk/server/stdio.js");var import_node_fs=require("fs");var import_node_path=require("path");var import_node_os=require("os");var import_node_crypto=require("crypto");var import_headless=require("@buoy-gg/license/headless");var KEY_NAMES=["BUOY_LICENSE_KEY","BUOY_KEY","EXPO_PUBLIC_BUOY_KEY","VITE_BUOY_KEY","REACT_APP_BUOY_KEY"];function readProjectKey(e,r=process.env){for(const t of KEY_NAMES)if(r[t]!==void 0)return
|
|
3
|
-
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await probeBroker(this.url);let r="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new NodeAccount(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=(0,import_sync_broker2.createBroker)({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await waitForBroker(this.url);try{(0,import_sync_broker2.writeBrokerToken)(this.broker.dashboardToken,(0,import_sync_broker2.brokerTokenPath)(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}r="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return r}connectSocket(){return new Promise((e,r)=>{const t=(0,import_socket.io)(this.url,{transports:["websocket"],query:{deviceName:DASHBOARD_NAME},auth:i=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));i(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let n=false;const o=()=>{if(n)return;n=true;this.log("connected to broker");e()};const s=setTimeout(()=>{if(n)return;n=true;r(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.ready===true;this.setAdmission(c);if(c){clearTimeout(s);this.rearmWatches();o()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const a=i=>i.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",i=>{if(n)return;n=true;clearTimeout(s);r(new Error(a(i)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${(0,import_sync_broker2.brokerTokenPath)(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=i;const d=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,d);this.pruneStaleDevices();if(c)this.rearmWatches()});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=snapKey(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.payload);this.snapshotAt.set(c,Date.now());const d=this.snapshotWaiters.get(c);if(d&&d.length){this.snapshotWaiters.set(c,[]);for(const l of d)l(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,r=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!r)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const n of t)n(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const r=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(r)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,n]of this.snapshotWaiters)if(t.startsWith(r)){this.snapshotWaiters.delete(t);for(const o of n)o(void 0)}for(const[t,n]of this.pendingActions)if(n.target===e||n.target===ALL_DEVICES){clearTimeout(n.timer);this.pendingActions.delete(t);n.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-DEVICE_RETENTION_MS;for(const[r,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(o=>o.deviceId===r))continue;this.lastSeen.delete(r);this.capabilities.delete(r);this.health.delete(r);this.devServerOrigins.delete(r);this.originProbed.delete(r);const n=`${r}::`;for(const o of[...this.snapshots.keys()]){if(o.startsWith(n)){this.snapshots.delete(o);this.snapshotAt.delete(o)}}for(const o of[...this.watching.keys()]){if(o.startsWith(n))this.watching.delete(o)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const r of e)r(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==DASHBOARD_NAME&&e.deviceId).map(e=>{const r=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:r?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(r=>{const t=r.tools.find(n=>n.toolId===e);return t?{deviceId:r.deviceId,deviceName:r.deviceName,actions:t.actions}:null}).filter(r=>r!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const r=this.connectedDevices();const t=r.filter(s=>!this.lockedDevices.has(s.deviceId));if(t.length===0){if(r.length>0){throw new Error(`Devices are connected (${r.map(s=>s.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const n=t.filter(s=>s.tools.length>0);if(n.length===1)return n[0].deviceId;if(n.length===0){const s=t.map(a=>a.deviceName).join(", ");throw new Error(`Connected devices (${s}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const o=n.map(s=>`${s.deviceName} (${s.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${o}`)}requireProDevice(e){this.requireAdmission();const r=s=>{if(!s)return"";return s.tier==="locked"?` The broker has not confirmed account access for "${s.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${s.deviceName}" is on the ${s.tier} tier.`};const t=s=>`Buoy Pro is required to use the MCP.${r(s)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===ALL_DEVICES){if(!this.connectedDevices().some(s=>s.isPro))throw new Error(t());return ALL_DEVICES}const n=this.resolveDevice(e);const o=this.connectedDevices().find(s=>s.deviceId===n);if(!o?.isPro)throw new Error(t(o));return n}healthOf(e){let r=this.health.get(e);if(!r){r={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,r)}return r}noteOk(e){const r=this.healthOf(e);r.consecutiveTimeouts=0;r.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return isUnresponsive(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,r,t){const n=this.healthOf(e);const{timeoutMs:o,usedFullBudget:s}=actionTimeoutMs({consecutiveTimeouts:n.consecutiveTimeouts,lastFullBudgetAt:n.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:UNRESPONSIVE_ACTION_TIMEOUT_MS,recoveryProbeMs:RECOVERY_PROBE_MS,toolId:r,action:t});if(s)n.lastFullBudgetAt=Date.now();return o}unresponsiveNote(e){const r=this.healthOf(e);const t=r.lastOkAt?Math.round((Date.now()-r.lastOkAt)/1e3):null;const n=diagnoseCrashed({consecutiveTimeouts:r.consecutiveTimeouts,lastOkAt:r.lastOkAt,now:Date.now(),unresponsiveAfterMs:UNRESPONSIVE_AFTER_MS});const o=[`The app on "${e}" is not responding (${r.consecutiveTimeouts} call${r.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,n?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const s=this.lastFatalFor(e);if(s)o.push(`Last captured error: ${s}`);o.push(s?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return o.join(" ")}lastFatalFor(e){const r=this.snapshots.get(snapKey(e,"console"));const t=r?.entries;if(!Array.isArray(t))return null;for(let n=t.length-1;n>=0;n--){const o=t[n];if(o?.fatal||o?.level==="error"){const s=(o.message??"").split("\n")[0];if(s)return s.length>300?`${s.slice(0,300)}\u2026`:s}}return null}snapshotAgeMs(e,r){this.requireAdmission();const t=this.snapshotAt.get(snapKey(r,e));return t?Date.now()-t:null}noteDevServerOrigin(e,r){if(typeof r==="string"&&/^https?:\/\//.test(r)){this.devServerOrigins.set(e,r)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const r=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!r)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const r=this.devServerOrigins.get(e);if(r)return r;const t=this.snapshots.get(snapKey(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,r){const t=snapKey(r,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:r});this.sendWatch(e,r)}sendWatch(e,r){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(r))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:r,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:r}of this.watching.values()){this.sendWatch(e,r)}}unwatch(e,r){const t=snapKey(r,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:r,watching:false})}cachedSnapshot(e,r){this.requireAdmission();this.requireDeviceAccount(r);return this.snapshots.get(snapKey(r,e))}async getSnapshot(e,r,t={}){const n=this.resolveDevice(r);const o=snapKey(n,e);this.ensureWatching(e,n);let s=t.timeoutMs??DEFAULT_SNAPSHOT_TIMEOUT_MS;if(t.timeoutMs===void 0&&this.isUnresponsive(n)){const d=this.healthOf(n);if(Date.now()-d.lastFullBudgetAt>RECOVERY_PROBE_MS){d.lastFullBudgetAt=Date.now()}else{s=UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS}}const a=this.waitForSnapshot(o,s);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:n});const i=await a;this.requireAdmission();this.requireDeviceAccount(n);if(i!==void 0){if(!t.silent)this.noteOk(n);return{deviceId:n,payload:i}}if(!t.silent)this.noteTimeout(n);const c=this.snapshots.get(o);if(c!==void 0){return{deviceId:n,payload:c,stale:this.staleNote(e,n)}}throw new Error(`Timed out waiting for "${e}" state from ${n} (is the tool active?). ${this.unresponsiveNote(n)}`)}staleNote(e,r){const t=this.snapshotAgeMs(e,r);const n=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${n}) \u2014 ${this.unresponsiveNote(r)}`}waitForSnapshot(e,r){return new Promise(t=>{const n=this.snapshotWaiters.get(e)??[];const o=setTimeout(()=>{const a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==s);this.snapshotWaiters.set(e,a);t(void 0)},r);const s=a=>{clearTimeout(o);t(a)};n.push(s);this.snapshotWaiters.set(e,n)})}async callAction(e,r,t,n,o={}){this.requireAdmission();const s=n===ALL_DEVICES?ALL_DEVICES:this.resolveDevice(n);if(!this.socket)throw new Error("SyncClient is not connected");const a=(0,import_node_crypto2.randomUUID)();const i=o.timeoutMs??this.timeoutFor(s,e,r);const c=await new Promise((d,l)=>{const u=setTimeout(()=>{this.pendingActions.delete(a);if(s!==ALL_DEVICES&&!o.silent)this.noteTimeout(s);l(new Error(`Action "${e}.${r}" got no answer in ${i}ms. `+(s===ALL_DEVICES?"No device responded.":this.unresponsiveNote(s))))},i);this.pendingActions.set(a,{resolve:d,reject:l,timer:u,target:s});this.socket.emit("devtool-action",{requestId:a,toolId:e,targetDeviceId:s,action:r,params:t??{}})});if(s!==ALL_DEVICES&&!o.silent)this.noteOk(s);if(!c.ok){throw new Error(c.error||`Action "${e}.${r}" failed on device`)}return c.result}};var snapKey=(e,r)=>`${e}::${r}`;function probeBroker(e,r=800){return new Promise(t=>{const n=import_node_http.default.get(e,o=>{o.resume();t((o.statusCode??0)>0)});n.setTimeout(r,()=>{n.destroy();t(false)});n.on("error",()=>t(false))})}async function waitForBroker(e,r=20){for(let t=0;t<r;t++){if(await probeBroker(e,300))return;await delay(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var delay=e=>new Promise(r=>setTimeout(r,e));var import_mcp=require("@modelcontextprotocol/sdk/server/mcp.js");var import_zod30=require("zod");var import_zod=require("zod");var import_node_fs2=require("fs");var import_node_os2=__toESM(require("os"));var import_node_path2=__toESM(require("path"));var VERSION="7.0.44"?"7.0.44":"0.0.0-dev";var PACKAGE_NAME="@buoy-gg/mcp";var CHECK_TTL_MS=24*60*60*1e3;var FETCH_TIMEOUT_MS=1500;var REGISTRY_URL=`https://registry.npmjs.org/${PACKAGE_NAME}/latest`;var CACHE_FILE=import_node_path2.default.join(import_node_os2.default.tmpdir(),"buoy-mcp-update-check.json");function isNewer(e,r){const t=s=>s.split("-")[0].split(".").map(a=>Number(a)||0);const n=t(e);const o=t(r);for(let s=0;s<3;s++){const a=n[s]??0;const i=o[s]??0;if(a>i)return true;if(a<i)return false}return false}async function readCache(){try{const e=await import_node_fs2.promises.readFile(CACHE_FILE,"utf8");const r=JSON.parse(e);if(typeof r.checkedAt==="number"&&typeof r.latest==="string"){return r}}catch{}return null}async function fetchLatest(e){const r=new AbortController;const t=setTimeout(()=>r.abort(),FETCH_TIMEOUT_MS);try{const n=await fetch(REGISTRY_URL,{signal:r.signal});if(!n.ok)return null;const o=await n.json();const s=typeof o.version==="string"?o.version:null;if(s){await import_node_fs2.promises.writeFile(CACHE_FILE,JSON.stringify({checkedAt:e,latest:s})).catch(()=>{})}return s}catch{return null}finally{clearTimeout(t)}}async function resolveLatest(e){const r=await readCache();if(r&&e-r.checkedAt<CHECK_TTL_MS)return r.latest;return fetchLatest(e)}async function getUpdateNotice(e=Date.now()){if(VERSION==="0.0.0-dev")return null;try{const r=await resolveLatest(e);if(r&&isNewer(r,VERSION)){return`A newer buoy-mcp is available (${r}; you're on ${VERSION}). Update by re-running: npx -y ${PACKAGE_NAME}@latest init \u2014 then restart your editor.`}}catch{}return null}function json(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function fail(e){return{isError:true,content:[{type:"text",text:e}]}}function registerGenericTools(e,r){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await getUpdateNotice();const n=r.listDevices();if(n.length===0){return json({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const o=n.filter(c=>c.isConnected);const s=o.some(c=>c.isPro);const a=o.some(c=>c.tier!=="locked");const i=o.filter(c=>c.collision);return json({devices:n,...i.length>0?{warning:`${i.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...s?{}:{hint:a?"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, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const n=r.toolActions(t);if(n.length===0){return fail(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return json({toolId:t,offerings:n})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:import_zod.z.string().describe("The tool id to read state from."),deviceId:import_zod.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:import_zod.z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:n,timeoutMs:o})=>{try{const s=r.requireProDevice(n);const{payload:a}=await r.getSnapshot(t,s,{timeoutMs:o});return json({toolId:t,deviceId:s,snapshot:a})}catch(s){return fail(errMsg(s))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor".'),action:import_zod.z.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:import_zod.z.record(import_zod.z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:import_zod.z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:import_zod.z.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:n,params:o,deviceId:s,timeoutMs:a})=>{try{const i=r.requireProDevice(s);const c=await r.callAction(t,n,o,i,{timeoutMs:a});return json({toolId:t,action:n,ok:true,result:c??null})}catch(i){return fail(errMsg(i))}})}function errMsg(e){return e instanceof Error?e.message:String(e)}var import_zod3=require("zod");var import_zod2=require("zod");var text=e=>({content:[{type:"text",text:e}]});var fail2=e=>({isError:true,content:[{type:"text",text:e}]});var errMsg2=e=>e instanceof Error?e.message:String(e);var deviceIdInput=import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).");function clampLimit(e,r){return typeof e==="number"&&e>0?e:r}function staleHeader(e){return e?`${e}
|
|
2
|
+
"use strict";var __create=Object.create;var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __getProtoOf=Object.getPrototypeOf;var __hasOwnProp=Object.prototype.hasOwnProperty;var __copyProps=(e,r,t,n)=>{if(r&&typeof r==="object"||typeof r==="function"){for(let o of __getOwnPropNames(r))if(!__hasOwnProp.call(e,o)&&o!==t)__defProp(e,o,{get:()=>r[o],enumerable:!(n=__getOwnPropDesc(r,o))||n.enumerable})}return e};var __toESM=(e,r,t)=>(t=e!=null?__create(__getProtoOf(e)):{},__copyProps(r||!e||!e.__esModule?__defProp(t,"default",{value:e,enumerable:true}):t,e));var import_stdio=require("@modelcontextprotocol/sdk/server/stdio.js");var import_node_fs=require("fs");var import_node_path=require("path");var import_node_os=require("os");var import_node_crypto=require("crypto");var import_headless=require("@buoy-gg/license/headless");var KEY_NAMES=["BUOY_LICENSE_KEY","BUOY_KEY","EXPO_PUBLIC_BUOY_KEY","VITE_BUOY_KEY","REACT_APP_BUOY_KEY"];function readProjectKey(e,r=process.env){for(const t of KEY_NAMES)if(r[t]!==void 0)return(0,import_headless.normalizeLicenseKey)(r[t])||null;for(const t of[".env.development.local",".env.local",".env"]){let n;try{n=(0,import_node_fs.readFileSync)((0,import_node_path.join)(e,t),"utf8")}catch{continue}const o=new Map;for(const s of n.split(/\r?\n/)){const a=s.match(/^\s*(?:export\s+)?([A-Z_][A-Z_0-9]*)\s*=\s*(.*)$/);if(!a)continue;const i=a[2].trim();const c=i.match(/^(['"])(.*?)\1\s*(?:#.*)?$/);o.set(a[1],c?c[2]:i.split("#")[0].trim())}for(const s of KEY_NAMES)if(o.has(s))return(0,import_headless.normalizeLicenseKey)(o.get(s))||null}return null}var NodeAccount=class{constructor(e,r={}){this.changed=e;this.key=null;this.cache=null;this.timer=null;this.unsubscribeSession=null;this.generation=0;this.busy=false;this.nextCheck=0;this.retry=0;this.lastAdmission=false;this.hasAccess=()=>this.hasKeyAccess()||this.session?.getStatus().state==="active";this.lastSessionSync=0;this.projectDir=(0,import_node_path.resolve)(r.projectDir??process.env.BUOY_PROJECT_DIR??process.cwd());const t=(0,import_node_crypto.createHash)("sha256").update(this.projectDir).digest("hex").slice(0,20);this.cacheFile=r.cacheFile??(0,import_node_path.join)((0,import_node_os.homedir)(),".buoy",`account-${t}.json`);this.session=r.session!==void 0?r.session:(0,import_headless.createNodeSession)({audience:import_headless.CLI_AUDIENCE,openBrowser:()=>{throw new Error("The MCP does not open a browser. Run `npx buoy login`.")}})}hasKeyAccess(){return(0,import_headless.resolveAccountAccess)({initialized:true,validating:false,key:this.key,tierSource:this.cache?.tier?"key":"none",verifiedAt:this.cache?.cachedAt??null}).status==="admitted"}start(){if(this.timer)return;if(this.session){this.unsubscribeSession=this.session.subscribe(()=>this.notify());void this.session.load().then(()=>this.notify(),()=>{})}try{const e=JSON.parse((0,import_node_fs.readFileSync)(this.cacheFile,"utf8"));if(e.valid&&(0,import_headless.verifyCacheSignature)(e))this.cache=e}catch{}void this.tick();this.timer=setInterval(()=>{void this.tick()},1e3);this.timer.unref()}stop(){++this.generation;this.unsubscribeSession?.();this.unsubscribeSession=null;if(this.timer)clearInterval(this.timer);this.timer=null;this.key=null;this.cache=null;this.notify()}notify(){const e=this.hasAccess();if(e===this.lastAdmission)return;this.lastAdmission=e;this.changed()}async tick(){if(this.session&&Date.now()-this.lastSessionSync>15e3){this.lastSessionSync=Date.now();void this.session.sync().catch(()=>{})}const e=readProjectKey(this.projectDir);if(e!==this.key){++this.generation;this.key=e;if(this.cache?.licenseKey!==e)this.cache=null;this.nextCheck=0;this.retry=0;if(!e)this.removeCache()}this.notify();if(!e||this.busy||Date.now()<this.nextCheck)return;if(this.cache&&this.hasKeyAccess()&&Date.now()-this.cache.lastValidatedAt<import_headless.LICENSE_LAUNCH_VALIDATION_TTL&&this.nextCheck===0){this.nextCheck=this.cache.lastValidatedAt+import_headless.LICENSE_LAUNCH_VALIDATION_TTL;return}this.busy=true;const r=this.generation;try{if((0,import_headless.isDevToken)(e)){const i=(0,import_headless.checkDevToken)(e);if(!i.ok){this.cache=null;this.removeCache()}else{const c=Math.min(Date.now(),i.claims.iat*1e3);const d=i.claims.tier==="free"?"free":"pro";this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`dev:${i.claims.sid}`,valid:true,tier:d,isPro:d==="pro",expiresAt:new Date(i.claims.exp*1e3).toISOString(),cachedAt:c,lastValidatedAt:Date.now()});this.persistCache()}this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}if((0,import_headless.isServiceKey)(e)){const i=await(0,import_headless.exchangeServiceKey)(e,{origin:process.env.BUOY_SITE_URL||"https://buoy.gg"});if(r!==this.generation||readProjectKey(this.projectDir)!==e)return;if(!i.ok){if(i.reason==="rejected"){this.cache=null;this.removeCache();this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL}else{this.nextCheck=Date.now()+(import_headless.LICENSE_TRANSIENT_RETRY_DELAYS[this.retry++]??import_headless.LICENSE_REVALIDATION_INTERVAL)}return}const c=Date.now();const d=i.claims.exp*1e3;this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`service:${i.claims.sid}`,valid:true,tier:"pro",isPro:true,expiresAt:new Date(d).toISOString(),cachedAt:c,lastValidatedAt:c});this.persistCache();this.retry=0;this.nextCheck=Math.max(c+6e4,d-60*60*1e3);return}if((0,import_headless.isBuoyToken)(e)){const i=(0,import_headless.verifyFreeToken)(e);if(!i){this.cache=null;this.removeCache()}else{const c=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:`token:${i.accountHash}`,valid:true,tier:"free",isPro:false,expiresAt:null,cachedAt:c,lastValidatedAt:c});this.persistCache()}this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}const t=await(0,import_headless.validateLicense)(e);if(r!==this.generation||readProjectKey(this.projectDir)!==e)return;const n=t.data?.relationships?.product?.data?.id;const o=t.meta.code==="EXPIRED"&&((0,import_headless.isRecognizedAccount)(t.data)||this.cache?.licenseKey===e);if(n&&n!==import_headless.KEYGEN_CONFIG.productId||!o&&!(0,import_headless.isAcceptedValidation)(t)){this.cache=null;this.removeCache();this.nextCheck=Date.now()+import_headless.LICENSE_REVALIDATION_INTERVAL;return}const s=o?"free":(0,import_headless.resolveTier)(t.data);const a=Date.now();this.cache=(0,import_headless.signCache)({licenseKey:e,licenseId:t.data?.id??"",valid:true,tier:s,isPro:s==="pro",expiresAt:o?null:t.data.attributes.expiry,cachedAt:a,lastValidatedAt:a});this.persistCache();this.retry=0;this.nextCheck=a+import_headless.LICENSE_REVALIDATION_INTERVAL}catch(t){if(r!==this.generation)return;const n=(0,import_headless.isTransientLicenseError)(t)?import_headless.LICENSE_TRANSIENT_RETRY_DELAYS[this.retry++]:void 0;this.nextCheck=Date.now()+(n??import_headless.LICENSE_REVALIDATION_INTERVAL)}finally{this.busy=false;this.notify()}}persistCache(){try{(0,import_node_fs.mkdirSync)((0,import_node_path.dirname)(this.cacheFile),{recursive:true,mode:448});(0,import_node_fs.writeFileSync)(`${this.cacheFile}.tmp`,JSON.stringify(this.cache),{mode:384});(0,import_node_fs.renameSync)(`${this.cacheFile}.tmp`,this.cacheFile)}catch{}}removeCache(){try{(0,import_node_fs.unlinkSync)(this.cacheFile)}catch{}}};var import_node_http=__toESM(require("http"));var import_node_crypto2=require("crypto");var import_socket=require("socket.io-client");var import_sync_broker2=require("@buoy-gg/sync-broker");var import_sync_broker=require("@buoy-gg/sync-broker");var BROKER_PORT=import_sync_broker.DEFAULT_BROKER_PORT;var BROKER_URL=`http://localhost:${BROKER_PORT}`;var DEFAULT_ACTION_TIMEOUT_MS=15e3;var UNRESPONSIVE_ACTION_TIMEOUT_MS=2500;var UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS=1200;var RECOVERY_PROBE_MS=2e4;var UNRESPONSIVE_AFTER_MS=3e4;var DEFAULT_SNAPSHOT_TIMEOUT_MS=5e3;var DEVICE_RETENTION_MS=15*6e4;var DASHBOARD_NAME="Dashboard";var ALL_DEVICES="All";function isRecoveryAction(e,r){return e==="app"&&(r==="ping"||r==="reloadApp")}function isUnresponsive(e){return e>=2}function actionTimeoutMs(e){if(isRecoveryAction(e.toolId,e.action)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(!isUnresponsive(e.consecutiveTimeouts)){return{timeoutMs:e.fullMs,usedFullBudget:false}}if(e.now-e.lastFullBudgetAt>e.recoveryProbeMs){return{timeoutMs:e.fullMs,usedFullBudget:true}}return{timeoutMs:e.shortMs,usedFullBudget:false}}function diagnoseCrashed(e){if(e.consecutiveTimeouts>=2)return true;if(e.lastOkAt>0&&e.now-e.lastOkAt>e.unresponsiveAfterMs){return true}return false}var SyncClient=class{constructor(e={}){this.socket=null;this.admitted=false;this.lockedDevices=new Set;this.hostAccount=null;this.broker=null;this.capabilities=new Map;this.devices=[];this.snapshots=new Map;this.watching=new Map;this.pendingActions=new Map;this.snapshotWaiters=new Map;this.snapshotAt=new Map;this.health=new Map;this.devServerOrigins=new Map;this.originProbed=new Set;this.lastSeen=new Map;this.deviceConnections=new Map;this.port=e.port??BROKER_PORT;this.url=e.url??(e.port?`http://localhost:${e.port}`:BROKER_URL);this.actionTimeoutMs=e.actionTimeoutMs??DEFAULT_ACTION_TIMEOUT_MS;this.log=e.log??(r=>process.stderr.write(`[buoy-mcp] ${r}
|
|
3
|
+
`));this.autoSpawn=e.autoSpawnBroker??true}async start(){const e=await probeBroker(this.url);let r="connected";if(!e){if(!this.autoSpawn){throw new Error(`No Buoy broker reachable at ${this.url} and autoSpawnBroker is off. Start the Buoy desktop app, or enable autoSpawnBroker.`)}this.log(`no broker at ${this.url} \u2014 spawning one in-process on :${this.port}`);this.hostAccount=new NodeAccount(()=>this.broker?.refreshAccess());this.hostAccount.start();this.broker=(0,import_sync_broker2.createBroker)({canUseDesktop:this.hostAccount.hasAccess,port:this.port,onError:t=>this.log(`broker error: ${t.code??t.message}`)});await waitForBroker(this.url);try{(0,import_sync_broker2.writeBrokerToken)(this.broker.dashboardToken,(0,import_sync_broker2.brokerTokenPath)(this.port))}catch(t){this.log(`could not write the broker token file: ${t.message}`)}r="spawned"}else{this.log(`found a running broker at ${this.url} \u2014 connecting as a dashboard`)}await this.connectSocket();return r}connectSocket(){return new Promise((e,r)=>{const t=(0,import_socket.io)(this.url,{transports:["websocket"],query:{deviceName:DASHBOARD_NAME},auth:i=>{const c=this.broker?.dashboardToken??(0,import_sync_broker2.readBrokerToken)((0,import_sync_broker2.brokerTokenPath)(this.port));i(c?{dashboardToken:c}:{})},reconnection:true,reconnectionDelay:500,timeout:4e3});this.socket=t;let n=false;const o=()=>{if(n)return;n=true;this.log("connected to broker");e()};const s=setTimeout(()=>{if(n)return;n=true;r(new Error("Broker account is locked or the broker needs an update. Sign in to Buoy Desktop or run npx buoy login for standalone MCP."))},5e3);t.on("buoy:access",i=>{const c=!!i&&typeof i==="object"&&i.protocol===1&&i.ready===true;this.setAdmission(c);if(c){clearTimeout(s);this.rearmWatches();o()}});t.on("connect",()=>{t.emit("buoy:access:request")});t.on("disconnect",()=>this.setAdmission(false,true));const a=i=>i.message===import_sync_broker2.DASHBOARD_TOKEN_REQUIRED;t.once("connect_error",i=>{if(n)return;n=true;clearTimeout(s);r(new Error(a(i)?`The broker at ${this.url} refused this MCP as a dashboard: the token in ${(0,import_sync_broker2.brokerTokenPath)(this.port)} is missing or stale. Update Buoy Desktop, or restart it so it writes the file.`:`failed to connect to broker at ${this.url}: ${i.message}`))});t.on("connect_error",i=>{if(!a(i)||t.active)return;setTimeout(()=>{if(this.socket===t&&!t.connected)t.connect()},1e3).unref?.()});t.on("devtool-capabilities",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;this.capabilities.set(i.persistentDeviceId,i);void this.learnDevServerOrigin(i.persistentDeviceId)});t.on("all-devices-update",i=>{if(!this.admitted)return;if(!Array.isArray(i))return;let c=false;for(const l of i){if(l.deviceId&&l.tier==="locked")this.lockDevice(String(l.deviceId));else if(l.deviceId&&(l.tier==="free"||l.tier==="pro")){c=this.lockedDevices.delete(String(l.deviceId))||c}}this.devices=i;const d=Date.now();for(const l of i)if(l.deviceId)this.lastSeen.set(l.deviceId,d);this.pruneStaleDevices();if(c)this.rearmWatches();else this.rearmReconnectedDevices(i)});t.on("devtool-sync",i=>{if(!this.admitted||this.lockedDevices.has(i.persistentDeviceId))return;const c=snapKey(i.persistentDeviceId,i.toolId);this.snapshots.set(c,i.payload);this.snapshotAt.set(c,Date.now());const d=this.snapshotWaiters.get(c);if(d&&d.length){this.snapshotWaiters.set(c,[]);for(const l of d)l(i.payload)}});t.on("devtool-action-result",i=>{if(!this.admitted)return;const c=this.pendingActions.get(i.requestId);if(!c)return;this.pendingActions.delete(i.requestId);clearTimeout(c.timer);c.resolve(i)})})}requireAdmission(){if(!this.admitted)throw new Error("A verified Buoy account and an updated broker are required. Sign in to Buoy Desktop, or run npx buoy login for standalone MCP.")}setAdmission(e,r=false){this.admitted=e;if(e)return;this.devices=[];this.lockedDevices.clear();this.capabilities.clear();this.snapshots.clear();this.snapshotAt.clear();this.devServerOrigins.clear();this.originProbed.clear();this.health.clear();this.lastSeen.clear();if(!r)this.watching.clear();for(const t of this.pendingActions.values()){clearTimeout(t.timer);t.reject(new Error("Buoy account access ended while the action was pending."))}this.pendingActions.clear();for(const t of this.snapshotWaiters.values())for(const n of t)n(void 0);this.snapshotWaiters.clear();if(this.socket)this.socket.sendBuffer=[]}requireDeviceAccount(e){if(this.lockedDevices.has(e))throw new Error("The device's Buoy account access ended. Sign in and verify its key.")}lockDevice(e){this.lockedDevices.add(e);this.capabilities.delete(e);this.health.delete(e);this.devServerOrigins.delete(e);this.originProbed.delete(e);const r=`${e}::`;for(const t of this.snapshots.keys())if(t.startsWith(r)){this.snapshots.delete(t);this.snapshotAt.delete(t)}for(const[t,n]of this.snapshotWaiters)if(t.startsWith(r)){this.snapshotWaiters.delete(t);for(const o of n)o(void 0)}for(const[t,n]of this.pendingActions)if(n.target===e||n.target===ALL_DEVICES){clearTimeout(n.timer);this.pendingActions.delete(t);n.reject(new Error("Device account access ended while the action was pending."))}}pruneStaleDevices(){const e=Date.now()-DEVICE_RETENTION_MS;for(const[r,t]of this.lastSeen){if(t>e)continue;if(this.devices.some(o=>o.deviceId===r))continue;this.lastSeen.delete(r);this.capabilities.delete(r);this.health.delete(r);this.devServerOrigins.delete(r);this.originProbed.delete(r);const n=`${r}::`;for(const o of[...this.snapshots.keys()]){if(o.startsWith(n)){this.snapshots.delete(o);this.snapshotAt.delete(o)}}for(const o of[...this.watching.keys()]){if(o.startsWith(n))this.watching.delete(o)}}}async stop(){this.setAdmission(false);this.hostAccount?.stop();this.hostAccount=null;for(const[,e]of this.pendingActions){clearTimeout(e.timer);e.reject(new Error("SyncClient stopped"))}this.pendingActions.clear();for(const[,e]of this.snapshotWaiters)for(const r of e)r(void 0);this.snapshotWaiters.clear();this.socket?.close();this.socket=null;if(this.broker){await this.broker.close();this.broker=null}}listDevices(){if(!this.admitted)return[];return this.devices.filter(e=>e.deviceName!==DASHBOARD_NAME&&e.deviceId).map(e=>{const r=this.capabilities.get(e.deviceId);return{deviceId:e.deviceId,deviceName:e.deviceName,platform:e.platform,isConnected:e.isConnected!==false,isPro:e.isPro===true,tier:e.tier==="free"||e.tier==="pro"?e.tier:e.tier===void 0&&e.isPro===true?"pro":"locked",collision:e.collision===true,tools:r?.tools??[]}})}connectedDevices(){return this.listDevices().filter(e=>e.isConnected)}toolActions(e){return this.listDevices().map(r=>{const t=r.tools.find(n=>n.toolId===e);return t?{deviceId:r.deviceId,deviceName:r.deviceName,actions:t.actions}:null}).filter(r=>r!==null)}resolveDevice(e){this.requireAdmission();if(e){this.requireDeviceAccount(e);return e}const r=this.connectedDevices();const t=r.filter(s=>!this.lockedDevices.has(s.deviceId));if(t.length===0){if(r.length>0){throw new Error(`Devices are connected (${r.map(s=>s.deviceName).join(", ")}), but the broker has not admitted their accounts. Check app account validation and the account announcement after reconnect; this does not prove the user is signed out.`)}throw new Error("No devices are connected to the broker. Open the app with Buoy devtools running.")}if(t.length===1)return t[0].deviceId;const n=t.filter(s=>s.tools.length>0);if(n.length===1)return n[0].deviceId;if(n.length===0){const s=t.map(a=>a.deviceName).join(", ");throw new Error(`Connected devices (${s}) are not exposing any Buoy tools yet. Make sure the app is in the foreground with devtools mounted, then retry.`)}const o=n.map(s=>`${s.deviceName} (${s.deviceId})`).join(", ");throw new Error(`Multiple devices connected \u2014 specify deviceId. Connected: ${o}`)}requireProDevice(e){this.requireAdmission();const r=s=>{if(!s)return"";return s.tier==="locked"?` The broker has not confirmed account access for "${s.deviceName}"; a locked status does not establish whether a key is configured.`:` The connected app "${s.deviceName}" is on the ${s.tier} tier.`};const t=s=>`Buoy Pro is required to use the MCP.${r(s)} Enter a license in the app, or \u2014 if it runs headless (no on-device UI) \u2014 pass \`licenseKey\` to \`<FloatingDevTools />\`. Run \`list_devices\` to check status.`;if(e===ALL_DEVICES){if(!this.connectedDevices().some(s=>s.isPro))throw new Error(t());return ALL_DEVICES}const n=this.resolveDevice(e);const o=this.connectedDevices().find(s=>s.deviceId===n);if(!o?.isPro)throw new Error(t(o));return n}healthOf(e){let r=this.health.get(e);if(!r){r={consecutiveTimeouts:0,lastOkAt:0,lastFullBudgetAt:0};this.health.set(e,r)}return r}noteOk(e){const r=this.healthOf(e);r.consecutiveTimeouts=0;r.lastOkAt=Date.now()}noteTimeout(e){this.healthOf(e).consecutiveTimeouts+=1}isUnresponsive(e){return isUnresponsive(this.healthOf(e).consecutiveTimeouts)}timeoutFor(e,r,t){const n=this.healthOf(e);const{timeoutMs:o,usedFullBudget:s}=actionTimeoutMs({consecutiveTimeouts:n.consecutiveTimeouts,lastFullBudgetAt:n.lastFullBudgetAt,now:Date.now(),fullMs:this.actionTimeoutMs,shortMs:UNRESPONSIVE_ACTION_TIMEOUT_MS,recoveryProbeMs:RECOVERY_PROBE_MS,toolId:r,action:t});if(s)n.lastFullBudgetAt=Date.now();return o}unresponsiveNote(e){const r=this.healthOf(e);const t=r.lastOkAt?Math.round((Date.now()-r.lastOkAt)/1e3):null;const n=diagnoseCrashed({consecutiveTimeouts:r.consecutiveTimeouts,lastOkAt:r.lastOkAt,now:Date.now(),unresponsiveAfterMs:UNRESPONSIVE_AFTER_MS});const o=[`The app on "${e}" is not responding (${r.consecutiveTimeouts} call${r.consecutiveTimeouts===1?"":"s"} unanswered${t!=null?`, last answered ${t}s ago`:""}).`,n?"It is most likely CRASHED \u2014 a fatal render error unmounts the React tree, and the dev tools live in that tree, so nothing on-device can answer until the app is reloaded.":"It may be busy, mid-reload, or crashing."];const s=this.lastFatalFor(e);if(s)o.push(`Last captured error: ${s}`);o.push(s?"Read the crash state with get_console / get_storage (served from the last sync), then reload_app to recover.":"Try get_console (served from the last sync) for the app's last words, then reload_app to recover.");return o.join(" ")}lastFatalFor(e){const r=this.snapshots.get(snapKey(e,"console"));const t=r?.entries;if(!Array.isArray(t))return null;for(let n=t.length-1;n>=0;n--){const o=t[n];if(o?.fatal||o?.level==="error"){const s=(o.message??"").split("\n")[0];if(s)return s.length>300?`${s.slice(0,300)}\u2026`:s}}return null}snapshotAgeMs(e,r){this.requireAdmission();const t=this.snapshotAt.get(snapKey(r,e));return t?Date.now()-t:null}noteDevServerOrigin(e,r){if(typeof r==="string"&&/^https?:\/\//.test(r)){this.devServerOrigins.set(e,r)}}async learnDevServerOrigin(e){if(this.devServerOrigins.has(e)||this.originProbed.has(e))return;this.originProbed.add(e);const r=this.capabilities.get(e)?.tools?.some(t=>t.toolId==="app");if(!r)return;try{const{payload:t}=await this.getSnapshot("app",e,{timeoutMs:4e3,silent:true});this.noteDevServerOrigin(e,t?.devServerOrigin)}catch{}}devServerOrigin(e){this.requireAdmission();const r=this.devServerOrigins.get(e);if(r)return r;const t=this.snapshots.get(snapKey(e,"app"));if(typeof t?.devServerOrigin==="string")return t.devServerOrigin;return null}ensureWatching(e,r){const t=snapKey(r,e);if(this.watching.has(t))return;this.watching.set(t,{toolId:e,targetDeviceId:r});this.sendWatch(e,r)}sendWatch(e,r){if(!this.admitted||!this.socket?.connected||this.lockedDevices.has(r))return;this.socket?.emit("devtool-watch",{toolId:e,targetDeviceId:r,watching:true,stream:false})}rearmWatches(){for(const{toolId:e,targetDeviceId:r}of this.watching.values()){this.sendWatch(e,r)}}rearmReconnectedDevices(e){for(const r of e){if(!r.deviceId||r.isConnected===false)continue;const t=String(r.deviceId);const n=`${r.id}|${r.connectionNonce??""}`;const o=this.deviceConnections.get(t);this.deviceConnections.set(t,n);if(o===void 0||o===n)continue;for(const{toolId:s,targetDeviceId:a}of this.watching.values()){if(a===t)this.sendWatch(s,a)}}}unwatch(e,r){const t=snapKey(r,e);if(!this.watching.delete(t))return;if(this.admitted&&this.socket?.connected)this.socket.emit("devtool-watch",{toolId:e,targetDeviceId:r,watching:false})}cachedSnapshot(e,r){this.requireAdmission();this.requireDeviceAccount(r);return this.snapshots.get(snapKey(r,e))}async getSnapshot(e,r,t={}){const n=this.resolveDevice(r);const o=snapKey(n,e);this.ensureWatching(e,n);let s=t.timeoutMs??DEFAULT_SNAPSHOT_TIMEOUT_MS;if(t.timeoutMs===void 0&&this.isUnresponsive(n)){const d=this.healthOf(n);if(Date.now()-d.lastFullBudgetAt>RECOVERY_PROBE_MS){d.lastFullBudgetAt=Date.now()}else{s=UNRESPONSIVE_SNAPSHOT_TIMEOUT_MS}}const a=this.waitForSnapshot(o,s);this.socket?.emit("request-devtool-state",{toolId:e,targetDeviceId:n});const i=await a;this.requireAdmission();this.requireDeviceAccount(n);if(i!==void 0){if(!t.silent)this.noteOk(n);return{deviceId:n,payload:i}}if(!t.silent)this.noteTimeout(n);const c=this.snapshots.get(o);if(c!==void 0){return{deviceId:n,payload:c,stale:this.staleNote(e,n)}}throw new Error(`Timed out waiting for "${e}" state from ${n} (is the tool active?). ${this.unresponsiveNote(n)}`)}staleNote(e,r){const t=this.snapshotAgeMs(e,r);const n=t==null?"an earlier sync":`${Math.round(t/1e3)}s ago`;return`\u26A0\uFE0F Served from the last sync (${n}) \u2014 ${this.unresponsiveNote(r)}`}waitForSnapshot(e,r){return new Promise(t=>{const n=this.snapshotWaiters.get(e)??[];const o=setTimeout(()=>{const a=(this.snapshotWaiters.get(e)??[]).filter(i=>i!==s);this.snapshotWaiters.set(e,a);t(void 0)},r);const s=a=>{clearTimeout(o);t(a)};n.push(s);this.snapshotWaiters.set(e,n)})}async callAction(e,r,t,n,o={}){this.requireAdmission();const s=n===ALL_DEVICES?ALL_DEVICES:this.resolveDevice(n);if(!this.socket)throw new Error("SyncClient is not connected");const a=(0,import_node_crypto2.randomUUID)();const i=o.timeoutMs??this.timeoutFor(s,e,r);const c=await new Promise((d,l)=>{const u=setTimeout(()=>{this.pendingActions.delete(a);if(s!==ALL_DEVICES&&!o.silent)this.noteTimeout(s);l(new Error(`Action "${e}.${r}" got no answer in ${i}ms. `+(s===ALL_DEVICES?"No device responded.":this.unresponsiveNote(s))))},i);this.pendingActions.set(a,{resolve:d,reject:l,timer:u,target:s});this.socket.emit("devtool-action",{requestId:a,toolId:e,targetDeviceId:s,action:r,params:t??{}})});if(s!==ALL_DEVICES&&!o.silent)this.noteOk(s);if(!c.ok){throw new Error(c.error||`Action "${e}.${r}" failed on device`)}return c.result}};var snapKey=(e,r)=>`${e}::${r}`;function probeBroker(e,r=800){return new Promise(t=>{const n=import_node_http.default.get(e,o=>{o.resume();t((o.statusCode??0)>0)});n.setTimeout(r,()=>{n.destroy();t(false)});n.on("error",()=>t(false))})}async function waitForBroker(e,r=20){for(let t=0;t<r;t++){if(await probeBroker(e,300))return;await delay(50)}throw new Error(`Spawned broker never became reachable at ${e}`)}var delay=e=>new Promise(r=>setTimeout(r,e));var import_mcp=require("@modelcontextprotocol/sdk/server/mcp.js");var import_zod30=require("zod");var import_zod=require("zod");var import_node_fs2=require("fs");var import_node_os2=__toESM(require("os"));var import_node_path2=__toESM(require("path"));var VERSION="7.0.45"?"7.0.45":"0.0.0-dev";var PACKAGE_NAME="@buoy-gg/mcp";var CHECK_TTL_MS=24*60*60*1e3;var FETCH_TIMEOUT_MS=1500;var REGISTRY_URL=`https://registry.npmjs.org/${PACKAGE_NAME}/latest`;var CACHE_FILE=import_node_path2.default.join(import_node_os2.default.tmpdir(),"buoy-mcp-update-check.json");function isNewer(e,r){const t=s=>s.split("-")[0].split(".").map(a=>Number(a)||0);const n=t(e);const o=t(r);for(let s=0;s<3;s++){const a=n[s]??0;const i=o[s]??0;if(a>i)return true;if(a<i)return false}return false}async function readCache(){try{const e=await import_node_fs2.promises.readFile(CACHE_FILE,"utf8");const r=JSON.parse(e);if(typeof r.checkedAt==="number"&&typeof r.latest==="string"){return r}}catch{}return null}async function fetchLatest(e){const r=new AbortController;const t=setTimeout(()=>r.abort(),FETCH_TIMEOUT_MS);try{const n=await fetch(REGISTRY_URL,{signal:r.signal});if(!n.ok)return null;const o=await n.json();const s=typeof o.version==="string"?o.version:null;if(s){await import_node_fs2.promises.writeFile(CACHE_FILE,JSON.stringify({checkedAt:e,latest:s})).catch(()=>{})}return s}catch{return null}finally{clearTimeout(t)}}async function resolveLatest(e){const r=await readCache();if(r&&e-r.checkedAt<CHECK_TTL_MS)return r.latest;return fetchLatest(e)}async function getUpdateNotice(e=Date.now()){if(VERSION==="0.0.0-dev")return null;try{const r=await resolveLatest(e);if(r&&isNewer(r,VERSION)){return`A newer buoy-mcp is available (${r}; you're on ${VERSION}). Update by re-running: npx -y ${PACKAGE_NAME}@latest init \u2014 then restart your editor.`}}catch{}return null}function json(e){return{content:[{type:"text",text:JSON.stringify(e,null,2)}]}}function fail(e){return{isError:true,content:[{type:"text",text:e}]}}function registerGenericTools(e,r){e.registerTool("list_devices",{description:"List the React Native devices connected to the Buoy broker and the dev tools each one exposes (toolId, version, available actions). Start here.",inputSchema:{}},async()=>{const t=await getUpdateNotice();const n=r.listDevices();if(n.length===0){return json({devices:[],hint:"No devices connected. Open the app with Buoy devtools running, then retry.",...t?{updateAvailable:t}:{}})}const o=n.filter(c=>c.isConnected);const s=o.some(c=>c.isPro);const a=o.some(c=>c.tier!=="locked");const i=o.filter(c=>c.collision);return json({devices:n,...i.length>0?{warning:`${i.map(c=>`"${c.deviceId}"`).join(", ")}: more than one app is registered under this id, so actions sent to it land on an arbitrary device. Give each device its own externalSync.deviceId (or leave it unset for a per-install default) before mutating anything.`}:{},...s?{}:{hint:a?"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, or pass `licenseKey` to `<FloatingDevTools />` if it runs headless.":"The broker has not confirmed account access for the connected devices, so data/action tools are blocked. A locked status can mean missing configuration, pending/failed validation, or a missing account announcement after reconnect; it does not prove the user is signed out. Check app account status and broker compatibility."},...t?{updateAvailable:t}:{}})});e.registerTool("list_tool_actions",{description:"List the actions a given tool exposes, and which connected devices offer it. Use the action names with call_action.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor", "network", "react-query".')}},async({toolId:t})=>{const n=r.toolActions(t);if(n.length===0){return fail(`No connected device exposes tool "${t}". Call list_devices to see available tools.`)}return json({toolId:t,offerings:n})});e.registerTool("get_snapshot",{description:"Read a tool's current state (its sync snapshot) from a device. Starts watching the tool and returns the next fresh snapshot.",inputSchema:{toolId:import_zod.z.string().describe("The tool id to read state from."),deviceId:import_zod.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),timeoutMs:import_zod.z.number().optional().describe("How long to wait for a fresh snapshot before falling back to the cached one.")}},async({toolId:t,deviceId:n,timeoutMs:o})=>{try{const s=r.requireProDevice(n);const{payload:a}=await r.getSnapshot(t,s,{timeoutMs:o});return json({toolId:t,deviceId:s,snapshot:a})}catch(s){return fail(errMsg(s))}});e.registerTool("call_action",{description:"Invoke any action a tool exposes (see list_tool_actions) and return its result. This is the generic escape hatch that works for every Buoy tool.",inputSchema:{toolId:import_zod.z.string().describe('The tool id, e.g. "perf-monitor".'),action:import_zod.z.string().describe('The action name, e.g. "startRecording", "clearAll".'),params:import_zod.z.record(import_zod.z.unknown()).optional().describe("JSON params object for the action (shape depends on the action)."),deviceId:import_zod.z.string().optional().describe('Target device id. Omit to auto-select one device, or "All" to broadcast.'),timeoutMs:import_zod.z.number().optional().describe("Max time to wait for the device's result.")}},async({toolId:t,action:n,params:o,deviceId:s,timeoutMs:a})=>{try{const i=r.requireProDevice(s);const c=await r.callAction(t,n,o,i,{timeoutMs:a});return json({toolId:t,action:n,ok:true,result:c??null})}catch(i){return fail(errMsg(i))}})}function errMsg(e){return e instanceof Error?e.message:String(e)}var import_zod3=require("zod");var import_zod2=require("zod");var text=e=>({content:[{type:"text",text:e}]});var fail2=e=>({isError:true,content:[{type:"text",text:e}]});var errMsg2=e=>e instanceof Error?e.message:String(e);var deviceIdInput=import_zod2.z.string().optional().describe("Target device id (auto-selected if one is connected).");function clampLimit(e,r){return typeof e==="number"&&e>0?e:r}function staleHeader(e){return e?`${e}
|
|
4
4
|
|
|
5
5
|
`:""}function inactiveToolMessage(e){return`No ${e.toolId} result from the device. The ${e.toolId} tool may be inactive, or the app needs a newer Buoy (${e.mcpTool} requires the ${e.action} action).`}function withDevice(e,r){return async t=>{try{const n=e.requireProDevice(t.deviceId);return await r(t,n)}catch(n){return fail2(errMsg2(n))}}}var PERF_TOOL_ID="perf-monitor";var clamp=(e,r,t)=>Math.max(r,Math.min(t,e));var WARMUP_CASE_NAME="__batch_warmup__";var THERMAL_MARK={nominal:"\u{1F7E2} nominal",fair:"\u{1F7E1} fair",serious:"\u{1F7E0} serious",critical:"\u{1F534} critical",unknown:"\u2014"};var FALLBACK={perCaseDurationMs:5e3,settleMs:600,navTimeoutMs:5e3,runsPerCase:3,coolDownMs:2e3,discardWarmupRuns:0};function pick(e,r,t){return e??r??t}function buildAutomationConfig(e,r={}){const t=[];const n=(e.targetRoute||"").trim();if(!n)throw new Error("targetRoute is required (the screen to benchmark).");let o=(e.bounceRoute||"").trim();if(!o)o=n==="/"?"/_buoy_bounce":"/";if(o===n){throw new Error(`bounceRoute ("${o}") must differ from targetRoute \u2014 it's the screen we bounce to so the test screen fully remounts each run.`)}if(!Array.isArray(e.cases)||e.cases.length===0){throw new Error("At least one case is required.")}const s=e.cases.map((u,m)=>{const y=(u.name||"").trim()||`case ${m+1}`;return{id:`case-${m}`,name:y,params:u.params??{},route:u.route}});const a=clamp(Math.round(pick(e.runsPerCase,r.runsPerCase,FALLBACK.runsPerCase)),1,10);const i=clamp(Math.round(pick(e.coolDownMs,r.coolDownMs,FALLBACK.coolDownMs)),0,3e4);const c=clamp(Math.round(pick(e.discardWarmupRuns,r.discardWarmupRuns,FALLBACK.discardWarmupRuns)),0,a-1);const d=e.shuffleCases??r.shuffleCases??s.length>3;if(s.length>3&&d===false){t.push("shuffleCases is off with >3 cases \u2014 later cases may be thermally throttled, biasing results. Consider enabling it.")}if(a===1){t.push("runsPerCase=1 \u2014 no median/variance; a single noisy run can mislead. 3+ is recommended.")}const l={targetRoute:n,bounceRoute:o,perCaseDurationMs:clamp(Math.round(pick(e.perCaseDurationMs,r.perCaseDurationMs,FALLBACK.perCaseDurationMs)),1e3,6e4),settleMs:clamp(Math.round(pick(e.settleMs,r.settleMs,FALLBACK.settleMs)),0,1e4),navTimeoutMs:clamp(Math.round(pick(e.navTimeoutMs,r.navTimeoutMs,FALLBACK.navTimeoutMs)),1e3,3e4),cases:s,runsPerCase:a,coolDownMs:i,discardWarmupRuns:c,discardWarmupCase:e.discardWarmupCase??r.discardWarmupCase??true,shuffleCases:d,reloadBetweenCases:e.reloadBetweenCases??r.reloadBetweenCases??false,captureRenders:e.captureRenders??r.captureRenders??true};return{config:l,warnings:t}}function describeBenchmarkSettings(e){const r=c=>c>=1e3?`${(c/1e3).toFixed(c%1e3?1:0)}s`:`${c}ms`;const t=e.runsPerCase??1;const n=estimateBatchMs(e);const o=n/6e4;const s=o>=1?`~${o.toFixed(1)} min`:`~${Math.round(n/1e3)}s`;const a=[`**${r(e.perCaseDurationMs)} measured per page**`,t>1?`**${t} runs each** (median taken)`:`**1 run each** (no median \u2014 noisier)`,e.reloadBetweenCases?"**full reload between pages** (no state leaks across tests)":"no reload between pages",e.shuffleCases?"shuffled order (cancels thermal bias)":"fixed order",e.captureRenders!==false?"render capture on (per-component why-data)":"render capture off"];const i=e.settleMs+e.perCaseDurationMs+(e.coolDownMs??0)+1800;return`${a.join(", ")}. That's ~${r(i)} wall-clock per page; for ${e.cases.length} page(s) \xD7 ${t} run(s), **${s}** total.`}function estimateBatchMs(e){const r=1800;const t=e.settleMs+e.perCaseDurationMs+e.coolDownMs+r;let n=e.cases.length*(e.runsPerCase??1)*t;if(e.reloadBetweenCases)n+=e.cases.length*3e3;return n}function computeFlags(e){const r=[];const t=e.deviceMaxRefreshRate;if(e.uiFps!=null&&t&&e.uiFps<t*.95){r.push("UI thread drops frames")}else if(e.uiJank!=null&&e.uiJank>=2){r.push("UI jank spikes")}if(e.cpu!=null&&e.cpu>=90)r.push("CPU near cap");if(e.jsJank!=null&&e.jsJank>=5)r.push("JS thread stalls");return r}function rankBatchFromIndex(e,r,t,n=[]){const o=e.filter(h=>h.batchId===r);const s=new Map(n.map(h=>[h.index,h.reason]));const a=Array.from(new Set(o.map(h=>h.batchIndex??0))).sort((h,f)=>h-f);const i=h=>{const f=o.filter(g=>(g.batchIndex??0)===h);if(f.length===0)return void 0;return f.find(g=>g.isMedianRun===true)??f.find(g=>g.runIndex===0||g.runIndex===void 0)??f[0]};const c=h=>typeof h==="number"?h:null;const d=a.map(h=>{const f=i(h);const g=o.filter(A=>(A.batchIndex??0)===h).length;const v=f?.name??t?.[h]?.name??`case ${h+1}`;const $=s.get(h)??f?.batchFailureReason;const k=Boolean($)||!f;const _={batchIndex:h,caseName:v,params:f?.params??{},runs:g,failed:k,failureReason:k?$??"no run recorded":void 0,uiFps:c(f?.uiFpsAvg),jsFps:c(f?.jsFpsAvg),cpu:c(f?.cpuAvg),memMb:c(f?.memMaxMb),jsJank:c(f?.jsJankFrames),uiJank:c(f?.uiJankFrames),deviceMaxRefreshRate:c(f?.deviceMaxRefreshRate),deltaUiFps:null,deltaJsFps:null,...f?.thermalState?{thermalState:f.thermalState}:{},renderCommits:c(f?.renderCommits),renderWasted:f?.renderWasted,topRenderers:f?.topRenderers??[]};return{..._,flags:k?[]:computeFlags(_)}}).filter(h=>h.caseName!==WARMUP_CASE_NAME).map((h,f)=>({...h,batchIndex:f}));const l=d[0];if(l){for(const h of d){if(h===l)continue;if(h.uiFps!=null&&l.uiFps!=null)h.deltaUiFps=round(h.uiFps-l.uiFps,1);if(h.jsFps!=null&&l.jsFps!=null)h.deltaJsFps=round(h.jsFps-l.jsFps,1)}}const u=d.filter(h=>!h.failed);const m=(h,f)=>{let g;let v=NaN;for(const $ of u){const k=h($);if(k==null)continue;if(g===void 0||f(k,v)){g=$;v=k}}return g?.caseName};const y=h=>h.jsJank==null&&h.uiJank==null?null:(h.jsJank??0)+(h.uiJank??0);const b={uiFps:m(h=>h.uiFps,(h,f)=>h>f),jsFps:m(h=>h.jsFps,(h,f)=>h>f),lowestCpu:m(h=>h.cpu,(h,f)=>h<f),lowestMem:m(h=>h.memMb,(h,f)=>h<f),fewestJank:m(y,(h,f)=>h<f)};const p=u.filter(h=>h.flags.length>0).map(h=>({caseName:h.caseName,flags:h.flags}));const w=u.filter(h=>h.flags.length===0).sort((h,f)=>(f.uiFps??0)-(h.uiFps??0)||(h.cpu??Infinity)-(f.cpu??Infinity)||(h.memMb??Infinity)-(f.memMb??Infinity)).map(h=>h.caseName);return{batchId:r,baselineName:l?.caseName??null,deviceMaxRefreshRate:u.find(h=>h.deviceMaxRefreshRate!=null)?.deviceMaxRefreshRate??null,rows:d,leaders:b,flagged:p,balanced:w}}function formatRankingMarkdown(e){const r=(u,m=1)=>u==null?"\u2014":u.toFixed(m);const t=u=>u==null?"":u>0?` (+${u.toFixed(1)})`:` (${u.toFixed(1)})`;const n=[];n.push(`### Benchmark batch \`${e.batchId}\``);n.push(`**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.`);n.push("");const o=e.rows.some(u=>u.thermalState);const s=o?" Thermal |":"";const a=o?"-------|":"";n.push(`| # | Case | UI FPS | JS FPS | CPU % | Mem MB | JS jank | UI jank |${s} Flags |`);n.push(`|---|------|-------:|-------:|------:|-------:|--------:|--------:|${a}-------|`);for(const u of e.rows){const m=o?` ${THERMAL_MARK[u.thermalState??"unknown"]??"\u2014"} |`:"";if(u.failed){n.push(`| ${u.batchIndex} | ${u.caseName} | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 | \u2014 |${m} \u274C FAILED (${u.failureReason}) |`);continue}const y=u.flags.length?`\u26A0\uFE0F ${u.flags.join("; ")}`:"\u2713 clean";n.push(`| ${u.batchIndex} | ${u.caseName} | ${r(u.uiFps)}${t(u.deltaUiFps)} | ${r(u.jsFps)}${t(u.deltaJsFps)} | ${r(u.cpu)} | ${r(u.memMb)} | ${r(u.jsJank,0)} | ${r(u.uiJank,0)} |${m} ${y} |`)}const i=e.rows.filter(u=>u.thermalState==="serious"||u.thermalState==="critical");if(i.length){n.push("");n.push(`**\u{1F525} Thermally throttled: ${i.map(u=>u.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.`)}n.push("");const c=e.leaders;const d=[c.uiFps&&`UI FPS \u2192 **${c.uiFps}**`,c.jsFps&&`JS FPS \u2192 **${c.jsFps}**`,c.lowestCpu&&`lowest CPU \u2192 **${c.lowestCpu}**`,c.lowestMem&&`lowest mem \u2192 **${c.lowestMem}**`,c.fewestJank&&`fewest jank \u2192 **${c.fewestJank}**`].filter(Boolean).join(" \xB7 ");if(d)n.push(`**Per-metric best:** ${d}`);n.push(e.balanced.length?`**No configured risk flags:** ${e.balanced.join(", ")}. Check missing metrics and workload requirements before choosing.`:`**No configured risk flags:** no candidates listed. Review failures, available metrics and flags before choosing.`);if(e.flagged.length){n.push(`**\u26A0\uFE0F At-risk:** ${e.flagged.map(u=>`${u.caseName} (${u.flags.join("; ")})`).join(" \xB7 ")}`)}const l=e.rows.filter(u=>!u.failed&&(u.renderCommits!=null||u.topRenderers.length>0)).map(u=>{const m=u.topRenderers.map(p=>`${p.name} \xD7${p.renders} (${p.totalMs}ms${p.cause?`, ${p.cause}`:""})`).join(", ");const y=u.renderCommits!=null?`${u.renderCommits} commits`:"";const b=u.renderWasted!=null?`${u.renderWasted} wasted`:"";return`- **${u.caseName}** \u2014 ${[y,b,m].filter(Boolean).join(": ")}`});if(l.length){n.push("");n.push("**Renders (median run)** \u2014 `wasted` = parent cascades + identity-only prop churn (removable). Cause per component: `propsUnstable` means a parent handed it a fresh function/object with unchanged values \u2014 fix upstream.");n.push(...l)}n.push("");n.push(`_Deltas vs baseline (case #0). Don't pick on one metric \u2014 explain the trade-off (e.g. "A is cheapest but drops UI frames \u2192 jank risk; B costs more CPU but caps nothing")._`);return n.join("\n")}function compareReportStats(e,r){const t=(n,o)=>({a:{avg:round(n.avg,1),p95:round(n.p95,1)},b:{avg:round(o.avg,1),p95:round(o.p95,1)},deltaAvg:round(o.avg-n.avg,1)});return{a:e.name,b:r.name,uiFps:t(e.stats.uiFps,r.stats.uiFps),jsFps:t(e.stats.jsFps,r.stats.jsFps),cpuUsage:t(e.stats.cpuUsage,r.stats.cpuUsage),memoryUsage:t(e.stats.memoryUsage,r.stats.memoryUsage),jsJankFrames:{a:e.stats.jsJankFrames,b:r.stats.jsJankFrames,delta:r.stats.jsJankFrames-e.stats.jsJankFrames},uiJankFrames:{a:e.stats.uiJankFrames,b:r.stats.uiJankFrames,delta:r.stats.uiJankFrames-e.stats.uiJankFrames},...compareRenders(e.renders,r.renders)}}function compareRenders(e,r){if(!e&&!r)return null;const t=n=>n??0;return{renders:{total:{a:t(e?.totalRenders),b:t(r?.totalRenders),delta:t(r?.totalRenders)-t(e?.totalRenders)},commits:{a:t(e?.totalCommits),b:t(r?.totalCommits),delta:t(r?.totalCommits)-t(e?.totalCommits)},wasted:{a:t(e?.wastedRenders),b:t(r?.wastedRenders),delta:t(r?.wastedRenders)-t(e?.wastedRenders)},causes:{a:e?.causes??{},b:r?.causes??{}}}}}function round(e,r){const t=Math.pow(10,r);return Math.round(e*t)/t}var POLL_INTERVAL_MS=1500;var delay2=e=>new Promise(r=>setTimeout(r,e));var json2=e=>({content:[{type:"text",text:JSON.stringify(e,null,2)}]});var caseSchema=import_zod3.z.object({name:import_zod3.z.string().describe("Display name for this variant (becomes the saved run's name)."),params:import_zod3.z.record(import_zod3.z.string()).optional().describe('Query params applied to the route for this case, e.g. { "renderer": "v2", "count": "100" }.'),route:import_zod3.z.string().optional().describe("Optional per-case route override; defaults to targetRoute.")});async function readDeviceSettings(e,r){try{const t=await e.callAction(PERF_TOOL_ID,"getAutomationConfig",{},r);if(!t)return{};const{perCaseDurationMs:n,settleMs:o,navTimeoutMs:s,runsPerCase:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:d,reloadBetweenCases:l,captureRenders:u}=t;return{perCaseDurationMs:n,settleMs:o,navTimeoutMs:s,runsPerCase:a,coolDownMs:i,discardWarmupRuns:c,shuffleCases:d,reloadBetweenCases:l,captureRenders:u}}catch{return{}}}async function readPerfSnapshot(e,r){const{payload:t}=await e.getSnapshot(PERF_TOOL_ID,r,{timeoutMs:4e3});return t}function batchIdsIn(e){const r=new Set;for(const t of e??[])if(t.batchId)r.add(t.batchId);return r}function runCountFor(e,r){return(e??[]).filter(t=>t.batchId===r).length}function newestBatchId(e){let r=null;for(const t of e??[]){if(t.batchId&&(!r||t.createdAt>r.at))r={id:t.batchId,at:t.createdAt}}return r?.id??null}function failuresFromIndex(e,r){const t=new Map;for(const n of e??[]){if(n.batchId===r&&n.batchFailureReason&&!t.has(n.batchIndex??0)){t.set(n.batchIndex??0,n.batchFailureReason)}}return[...t.entries()].map(([n,o])=>({index:n,reason:o}))}function expectedRunsFor(e){const r=e.cases.length+(e.discardWarmupCase===true?1:0);return r*(e.runsPerCase??1)}var SETTLE_WAIT_MS=1e4;function isRunnerBusy(e){return!!e&&e!=="idle"&&e!=="done"&&e!=="cancelled"}async function waitForRunnerSettled(e,r,t,n=SETTLE_WAIT_MS){const o=Date.now();while(Date.now()-o<n){const s=await readPerfSnapshot(e,r).catch(()=>null);if(s&&!isRunnerBusy(s.live?.automation?.phase))return true;await delay2(POLL_INTERVAL_MS)}t(`automation still busy after ${Math.round(n/1e3)}s`);return false}async function waitForBatchByIndex(e,r,t){const{preBatchIds:n,expectedRuns:o,maxWaitMs:s,log:a}=t;const i=Date.now();let c="";let d=false;let l=0;while(Date.now()-i<s){let y;try{y=await readPerfSnapshot(e,r)}catch{await delay2(POLL_INTERVAL_MS);continue}const b=y.index??[];const p=y.live?.automation?.phase??"idle";const w=y.live?.automationCompleted;if(w?.phase==="cancelled"&&!n.has(w.batchId)){return{batchId:w.batchId,failures:[],cancelled:true}}if(w?.phase==="done"&&!n.has(w.batchId)){return{batchId:w.batchId,failures:w.failures??failuresFromIndex(b,w.batchId)}}if(p!=="idle")d=true;if(p!==c){c=p;a(`automation phase: ${p}`)}const h=[...batchIdsIn(b)].find(f=>!n.has(f));if(h){const f=runCountFor(b,h);if(o>0&&f>=o){await waitForRunnerSettled(e,r,a);a(`batch ${h} complete (${f}/${o} runs saved)`);return{batchId:h,failures:failuresFromIndex(b,h)}}if(p==="idle"&&d&&f>0){if(++l>=3){a(`batch ${h} settled (${f} runs, automation idle)`);return{batchId:h,failures:failuresFromIndex(b,h)}}}else{l=0}}await delay2(POLL_INTERVAL_MS)}const u=await readPerfSnapshot(e,r).catch(()=>null);const m=u?[...batchIdsIn(u.index)].find(y=>!n.has(y)):void 0;if(m&&u)return{batchId:m,failures:failuresFromIndex(u.index,m),timedOut:true};throw new Error(`Batch did not start within ${Math.round(s/1e3)}s (last phase: ${c||"idle"}). Check get_automation_status \u2014 the device may not have navigated.`)}function registerPerfMonitorTools(e,r){e.registerTool("run_benchmark_batch",{description:"Run a perf-monitor benchmark batch and return per-metric leaders, risk flags and a comparison of available case results. Give it a target route and a matrix of cases (each a name + query params that select a variant on that route); it navigates, records, and benchmarks each case on the device, then ranks them. This blocks until the batch completes (can be minutes for large batches). The app must have the target route render its variants from the params.",inputSchema:{targetRoute:import_zod3.z.string().describe('The screen to benchmark, e.g. "/perf-test". Reads case params to pick a variant.'),cases:import_zod3.z.array(caseSchema).min(1).describe("The variants to compare. Case #0 is the baseline for deltas."),bounceRoute:import_zod3.z.string().optional().describe('Screen to bounce to between runs so the test screen fully remounts. Defaults to "/" (or a stub if target is "/").'),runsPerCase:import_zod3.z.number().optional().describe("Runs per case; the median becomes the canonical result. Default 3 (1\u201310)."),perCaseDurationMs:import_zod3.z.number().optional().describe("How long to record each run. Default 5000."),settleMs:import_zod3.z.number().optional().describe("Idle gap after navigation before recording. Default 600."),coolDownMs:import_zod3.z.number().optional().describe("Idle between runs/cases for thermal recovery. Default 2000."),navTimeoutMs:import_zod3.z.number().optional().describe("Max wait for navigation per run. Default 5000."),discardWarmupRuns:import_zod3.z.number().optional().describe("Discard first N runs of each case before taking the median. Default 0."),shuffleCases:import_zod3.z.boolean().optional().describe("Interleave and shuffle case order to reduce order-related bias. Default on for >3 cases."),reloadBetweenCases:import_zod3.z.boolean().optional().describe("Fully reload JS between cases (needs <AutomationResumer/> mounted). Default false."),captureRenders:import_zod3.z.boolean().optional().describe("Capture per-component render counts/durations during each run (needs @buoy-gg/highlight-updates installed; dev builds only). Adds instrumentation overhead that can vary with the workload. Default true."),deviceId:import_zod3.z.string().optional().describe("Target device id. Omit to auto-select when one device is connected."),maxWaitMs:import_zod3.z.number().optional().describe("Safety cap for how long to wait for completion. Defaults to ~3\xD7 the estimate + 60s.")}},async t=>{const n=o=>process.stderr.write(`[buoy-mcp] ${o}
|
|
6
6
|
`);try{const o=r.requireProDevice(t.deviceId);const s=await readDeviceSettings(r,o);const{config:a,warnings:i}=buildAutomationConfig(t,s);const c=estimateBatchMs(a);const d=t.maxWaitMs??c*3+6e4;const l=expectedRunsFor(a);if(!await waitForRunnerSettled(r,o,n)){return fail2("Another benchmark batch is still running on this device. Wait for it to finish, or cancel it (call_action perf-monitor cancelAutomation), then retry.")}await r.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},o).catch(()=>{});await r.callAction(PERF_TOOL_ID,"setEnabled",{enabled:true},o).catch(()=>{});await r.callAction(PERF_TOOL_ID,"refreshIndex",{},o).catch(()=>{});const u=await readPerfSnapshot(r,o);const m=batchIdsIn(u.index);n(`starting batch: ${a.cases.length} cases \xD7 ${a.runsPerCase} runs (~${Math.round(c/1e3)}s estimated) on ${o}`);await r.callAction(PERF_TOOL_ID,"startAutomation",{config:a},o);const y=await waitForBatchByIndex(r,o,{preBatchIds:m,expectedRuns:l,maxWaitMs:d,log:n});if(y.cancelled){await r.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},o).catch(()=>{});return fail2(`Batch ${y.batchId} was cancelled before completing.`)}await r.callAction(PERF_TOOL_ID,"refreshIndex",{},o).catch(()=>{});const b=await readPerfSnapshot(r,o);const p=rankBatchFromIndex(b.index??[],y.batchId,a.cases,y.failures);await r.callAction(PERF_TOOL_ID,"acknowledgeAutomation",{},o).catch(()=>{});await r.callAction(PERF_TOOL_ID,"setEnabled",{enabled:false},o).catch(()=>{});const w=formatRankingMarkdown(p);const h={batchId:y.batchId,leaders:p.leaders,balanced:p.balanced,flagged:p.flagged,rows:p.rows,failures:y.failures,warnings:i,estimateMs:c,...y.timedOut?{note:"Wait cap hit \u2014 results may be partial (some runs may still be saving)."}:{}};return text(`${w}
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{SyncClient as i,createMcpServer as p,runInit as a}from"./chunk-
|
|
2
|
+
import{SyncClient as i,createMcpServer as p,runInit as a}from"./chunk-GFFMF22U.mjs";import{StdioServerTransport as l}from"@modelcontextprotocol/sdk/server/stdio.js";console.log=(...e)=>console.error(...e);console.info=(...e)=>console.error(...e);console.debug=(...e)=>console.error(...e);async function d(){const e=process.env.BUOY_BROKER_PORT;const s=new i({url:process.env.BUOY_BROKER_URL,port:e?Number(e):void 0,autoSpawnBroker:process.env.BUOY_NO_SPAWN!=="1"});const o=p(s);const c=new l;let n=false;const r=t=>{if(n)return;n=true;process.stderr.write(`[buoy-mcp] shutting down (${t})
|
|
3
3
|
`);setTimeout(()=>process.exit(0),2e3).unref();void(async()=>{await s.stop().catch(()=>{});await o.close().catch(()=>{});process.exit(0)})()};process.stdin.once("end",()=>r("stdin closed"));process.stdin.once("close",()=>r("stdin closed"));o.server.onclose=()=>r("transport closed");process.on("SIGINT",()=>r("SIGINT"));process.on("SIGTERM",()=>r("SIGTERM"));process.on("SIGHUP",()=>r("SIGHUP"));await o.connect(c);process.stderr.write(`[buoy-mcp] serving MCP over stdio; connecting to broker\u2026
|
|
4
4
|
`);s.start().then(t=>process.stderr.write(`[buoy-mcp] broker ${t}
|
|
5
5
|
`)).catch(t=>process.stderr.write(`[buoy-mcp] broker connection failed: ${t?.message??t} \u2014 tools will report no devices until it recovers
|
package/dist/index.d.mts
CHANGED
|
@@ -219,6 +219,18 @@ declare class SyncClient {
|
|
|
219
219
|
* failure this whole capture-mode change exists to remove.
|
|
220
220
|
*/
|
|
221
221
|
private rearmWatches;
|
|
222
|
+
/** Each device's current connection (socket id + nonce), to spot a reload. */
|
|
223
|
+
private deviceConnections;
|
|
224
|
+
/**
|
|
225
|
+
* Re-arm the watches for a device that came back on a NEW connection.
|
|
226
|
+
*
|
|
227
|
+
* A relaunch or JS reload gives the app a fresh socket and fresh adapters,
|
|
228
|
+
* whose capture starts switched off. Our watches still say "held", so
|
|
229
|
+
* `ensureWatching` short-circuited and the device never recorded again: after
|
|
230
|
+
* one restart, the storage timeline stayed empty for the rest of the session
|
|
231
|
+
* (found by the BK storage suite — every case after a relaunch read nothing).
|
|
232
|
+
*/
|
|
233
|
+
private rearmReconnectedDevices;
|
|
222
234
|
/** Stop streaming a tool (releases the device-side backpressure). */
|
|
223
235
|
unwatch(toolId: string, targetDeviceId: string): void;
|
|
224
236
|
/** Last snapshot received for a tool/device, if any. */
|
package/dist/index.d.ts
CHANGED
|
@@ -219,6 +219,18 @@ declare class SyncClient {
|
|
|
219
219
|
* failure this whole capture-mode change exists to remove.
|
|
220
220
|
*/
|
|
221
221
|
private rearmWatches;
|
|
222
|
+
/** Each device's current connection (socket id + nonce), to spot a reload. */
|
|
223
|
+
private deviceConnections;
|
|
224
|
+
/**
|
|
225
|
+
* Re-arm the watches for a device that came back on a NEW connection.
|
|
226
|
+
*
|
|
227
|
+
* A relaunch or JS reload gives the app a fresh socket and fresh adapters,
|
|
228
|
+
* whose capture starts switched off. Our watches still say "held", so
|
|
229
|
+
* `ensureWatching` short-circuited and the device never recorded again: after
|
|
230
|
+
* one restart, the storage timeline stayed empty for the rest of the session
|
|
231
|
+
* (found by the BK storage suite — every case after a relaunch read nothing).
|
|
232
|
+
*/
|
|
233
|
+
private rearmReconnectedDevices;
|
|
222
234
|
/** Stop streaming a tool (releases the device-side backpressure). */
|
|
223
235
|
unwatch(toolId: string, targetDeviceId: string): void;
|
|
224
236
|
/** Last snapshot received for a tool/device, if any. */
|