@buoy-gg/mcp 7.0.2 → 7.0.4
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/README.md +14 -0
- package/dist/{chunk-XIN7JPXX.mjs → chunk-EC6YNLYA.mjs} +566 -5
- package/dist/cli.js +576 -6
- package/dist/cli.mjs +11 -2
- package/dist/index.d.mts +45 -4
- package/dist/index.d.ts +45 -4
- package/dist/index.js +566 -5
- package/dist/index.mjs +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -18,6 +18,20 @@ One command, non-destructive:
|
|
|
18
18
|
|
|
19
19
|
The config it writes launches the server via `npx -y @buoy-gg/mcp@latest`, so every editor restart re-resolves the newest published version — you never get pinned to a stale copy. Restart your editor, open your app with Buoy running, and start with `list_devices`.
|
|
20
20
|
|
|
21
|
+
### Corporate / private npm registries
|
|
22
|
+
|
|
23
|
+
If your machine's `.npmrc` points npm at a private registry that isn't reachable (a common setup on work laptops, e.g. off-VPN), `npx @latest` would hang trying to download the package on every editor launch — so `init` probes that registry first and, when it's unreachable, automatically installs a **pinned local copy** (from public npm) and writes a `node <path>` config instead. That takes the network off the startup path entirely.
|
|
24
|
+
|
|
25
|
+
You can also force it:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx -y @buoy-gg/mcp@latest init --local # always install locally, no network on startup
|
|
29
|
+
npx -y @buoy-gg/mcp@latest init --npx # always use the auto-updating npx entry
|
|
30
|
+
npx -y @buoy-gg/mcp@latest init --registry <url> # registry the local install pulls from
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
To update a local install later, just re-run `init`.
|
|
34
|
+
|
|
21
35
|
## What your agent can do
|
|
22
36
|
|
|
23
37
|
**Read the runtime**
|
|
@@ -296,7 +296,7 @@ async function waitForBroker(url, attempts = 20) {
|
|
|
296
296
|
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
297
297
|
|
|
298
298
|
// src/version.ts
|
|
299
|
-
var VERSION = "7.0.
|
|
299
|
+
var VERSION = "7.0.4" ? "7.0.4" : "0.0.0-dev";
|
|
300
300
|
var PACKAGE_NAME = "@buoy-gg/mcp";
|
|
301
301
|
|
|
302
302
|
// src/update/checkForUpdate.ts
|
|
@@ -2006,7 +2006,7 @@ function registerScreenshotTools(server, sync) {
|
|
|
2006
2006
|
|
|
2007
2007
|
// src/server.ts
|
|
2008
2008
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2009
|
-
import { z as
|
|
2009
|
+
import { z as z15 } from "zod";
|
|
2010
2010
|
|
|
2011
2011
|
// src/tools/packs/interact.ts
|
|
2012
2012
|
import { z as z12 } from "zod";
|
|
@@ -2112,6 +2112,439 @@ ${elements.map(formatElement).join("\n")}`);
|
|
|
2112
2112
|
);
|
|
2113
2113
|
}
|
|
2114
2114
|
|
|
2115
|
+
// src/tools/packs/jsThread.ts
|
|
2116
|
+
import { z as z13 } from "zod";
|
|
2117
|
+
var JS_TOP_TOOL_ID = "js-top";
|
|
2118
|
+
var text11 = (s) => ({ content: [{ type: "text", text: s }] });
|
|
2119
|
+
var fail13 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
|
|
2120
|
+
var errMsg13 = (e) => e instanceof Error ? e.message : String(e);
|
|
2121
|
+
var SPARK_CHARS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
|
|
2122
|
+
function sparkline(values) {
|
|
2123
|
+
const max = Math.max(1, ...values);
|
|
2124
|
+
return values.map((v) => SPARK_CHARS[Math.min(7, Math.floor(v / max * 7.999))]).join("");
|
|
2125
|
+
}
|
|
2126
|
+
function formatTopMarkdown(snap, durationMs) {
|
|
2127
|
+
const lines = [];
|
|
2128
|
+
lines.push(`## JS thread top (${(durationMs / 1e3).toFixed(1)}s sample)`);
|
|
2129
|
+
lines.push("");
|
|
2130
|
+
lines.push(`Thread busy: **${snap.busyPct.toFixed(1)}%** of the trailing 5s window.`);
|
|
2131
|
+
if (snap.freezeSummary.count > 0) {
|
|
2132
|
+
lines.push(
|
|
2133
|
+
`Freezes (\u226550ms single-task blocks, last 60s): **${snap.freezeSummary.count}**, worst **${snap.freezeSummary.worstMs}ms** \u2014 during these, taps and JS animations stall.`
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
if (!snap.tiers.bridge) {
|
|
2137
|
+
lines.push(
|
|
2138
|
+
"_New Architecture device: touch/React commit work appears as \u201Cunattributed\u201D; timers, rAF and Promise chains are fully attributed._"
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
lines.push("");
|
|
2142
|
+
lines.push("| # | task origin | api | 5s ms | % busy | calls | avg ms | max ms | freezes |");
|
|
2143
|
+
lines.push("|---|---|---|---|---|---|---|---|---|");
|
|
2144
|
+
snap.rows.slice(0, 15).forEach((r, i) => {
|
|
2145
|
+
const freezeCell = r.freezes > 0 ? `\u26A0 ${r.freezes} (worst ${r.worstFreezeMs}ms)` : "\u2013";
|
|
2146
|
+
lines.push(
|
|
2147
|
+
`| ${i + 1} | ${r.label} | ${r.api} | ${r.windowMs.toFixed(1)} | ${r.pctOfBusy.toFixed(1)} | ${r.calls} | ${r.avgMs.toFixed(2)} | ${r.maxMs.toFixed(1)} | ${freezeCell} |`
|
|
2148
|
+
);
|
|
2149
|
+
});
|
|
2150
|
+
lines.push("");
|
|
2151
|
+
lines.push(
|
|
2152
|
+
"_Drill into any row with `get_js_thread_origin_detail` using its `key` from the JSON below._"
|
|
2153
|
+
);
|
|
2154
|
+
return lines.join("\n");
|
|
2155
|
+
}
|
|
2156
|
+
function formatDetailMarkdown(d) {
|
|
2157
|
+
const lines = [];
|
|
2158
|
+
lines.push(`## Origin detail \u2014 ${d.label}`);
|
|
2159
|
+
lines.push("");
|
|
2160
|
+
lines.push(`API: \`${d.api}\` \xB7 key: \`${d.key}\``);
|
|
2161
|
+
lines.push("");
|
|
2162
|
+
lines.push(`Activity last 5s (250ms buckets): \`${sparkline(d.buckets)}\` \u2014 ${d.windowMs.toFixed(1)}ms in window, ${d.pctOfBusy.toFixed(1)}% of busy time.`);
|
|
2163
|
+
lines.push("");
|
|
2164
|
+
lines.push("| runs | scheduled | avg/run | max/run | total | last run | freezes (60s) | worst freeze |");
|
|
2165
|
+
lines.push("|---|---|---|---|---|---|---|---|");
|
|
2166
|
+
lines.push(
|
|
2167
|
+
`| ${d.calls} | ${d.scheduleCount} | ${d.avgMs.toFixed(2)}ms | ${d.maxMs.toFixed(1)}ms | ${d.totalMs.toFixed(1)}ms | ${d.lastSeenAgoMs > 0 ? `${Math.round(d.lastSeenAgoMs / 1e3)}s ago` : "\u2014"} | ${d.freezes} | ${d.worstFreezeMs > 0 ? `${d.worstFreezeMs}ms` : "\u2014"} |`
|
|
2168
|
+
);
|
|
2169
|
+
if (d.caller || d.file) {
|
|
2170
|
+
lines.push("");
|
|
2171
|
+
lines.push(`Scheduled from: **${d.caller ? `${d.caller}()` : "unknown caller"}**${d.file ? ` \u2014 ${d.file}${d.line != null ? `:${d.line}` : ""}` : ""}`);
|
|
2172
|
+
if (d.frames?.length) {
|
|
2173
|
+
lines.push(
|
|
2174
|
+
`Raw frames (Hermes bundle offsets): ${d.frames.map((f) => `${f.file}:${f.line}:${f.col}`).join(" \xB7 ")}`
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
} else if (d.api !== "unattributed" && d.api !== "other") {
|
|
2178
|
+
lines.push("");
|
|
2179
|
+
lines.push(
|
|
2180
|
+
d.promoted ? "_Scheduling stack not captured yet \u2014 the next schedule from this site records it._" : "_Not hot enough for stack capture yet (threshold: 25 schedules or 50ms total)._"
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
return lines.join("\n");
|
|
2184
|
+
}
|
|
2185
|
+
function registerJsThreadTools(server, sync) {
|
|
2186
|
+
server.registerTool(
|
|
2187
|
+
"get_js_thread_top",
|
|
2188
|
+
{
|
|
2189
|
+
description: "Task Manager for the React Native JS thread: sample for a few seconds and return a ranked table of which task origins (timers, Promise chains, rAF callbacks, bridge call-ins) consumed JS-thread time \u2014 plus thread busy% and freeze (50ms+ block) attribution. Use when JS FPS is low and you need to know WHAT is eating the thread. To profile one specific interaction, pass clearFirst:true, then drive the UI with tap_element while the sample runs. Drill into a row with get_js_thread_origin_detail. Requires @buoy-gg/js-top on the device.",
|
|
2190
|
+
inputSchema: {
|
|
2191
|
+
deviceId: z13.string().optional().describe("Target device id (auto-selected if one is connected)."),
|
|
2192
|
+
durationMs: z13.number().optional().describe("How long to sample before reading the table. Default 3000, max 30000."),
|
|
2193
|
+
clearFirst: z13.boolean().optional().describe("Reset all stats before sampling, so the result attributes only what happens during this sample.")
|
|
2194
|
+
}
|
|
2195
|
+
},
|
|
2196
|
+
async ({ deviceId, durationMs, clearFirst }) => {
|
|
2197
|
+
try {
|
|
2198
|
+
const target = sync.requireProDevice(deviceId);
|
|
2199
|
+
const duration = Math.min(Math.max(durationMs ?? 3e3, 250), 3e4);
|
|
2200
|
+
const snap = await sync.callAction(
|
|
2201
|
+
JS_TOP_TOOL_ID,
|
|
2202
|
+
"sample",
|
|
2203
|
+
{ durationMs: duration, clearFirst: clearFirst === true },
|
|
2204
|
+
target
|
|
2205
|
+
);
|
|
2206
|
+
if (!snap) return fail13("Device returned no snapshot \u2014 is @buoy-gg/js-top installed in the app?");
|
|
2207
|
+
const md = formatTopMarkdown(snap, duration);
|
|
2208
|
+
const slim = {
|
|
2209
|
+
busyPct: Math.round(snap.busyPct * 10) / 10,
|
|
2210
|
+
freezeSummary: snap.freezeSummary,
|
|
2211
|
+
tiers: snap.tiers,
|
|
2212
|
+
rows: snap.rows.slice(0, 15).map((r) => ({
|
|
2213
|
+
key: r.key,
|
|
2214
|
+
api: r.api,
|
|
2215
|
+
windowMs: Math.round(r.windowMs * 10) / 10,
|
|
2216
|
+
pctOfBusy: Math.round(r.pctOfBusy * 10) / 10,
|
|
2217
|
+
calls: r.calls,
|
|
2218
|
+
avgMs: Math.round(r.avgMs * 100) / 100,
|
|
2219
|
+
maxMs: Math.round(r.maxMs * 10) / 10,
|
|
2220
|
+
totalMs: Math.round(r.totalMs),
|
|
2221
|
+
freezes: r.freezes,
|
|
2222
|
+
worstFreezeMs: r.worstFreezeMs
|
|
2223
|
+
}))
|
|
2224
|
+
};
|
|
2225
|
+
return text11(`${md}
|
|
2226
|
+
|
|
2227
|
+
\`\`\`json
|
|
2228
|
+
${JSON.stringify(slim)}
|
|
2229
|
+
\`\`\``);
|
|
2230
|
+
} catch (e) {
|
|
2231
|
+
return fail13(errMsg13(e));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
);
|
|
2235
|
+
server.registerTool(
|
|
2236
|
+
"get_js_thread_origin_detail",
|
|
2237
|
+
{
|
|
2238
|
+
description: 'Inspect one js-top task origin in depth: schedules vs runs, avg/max/total exclusive ms, a 5s activity sparkline, freeze attribution, and the captured scheduling site (caller function + stack frames). Pass a row `key` from get_js_thread_top\'s JSON (e.g. "interval|pollFeed" or "unattributed" for the system row).',
|
|
2239
|
+
inputSchema: {
|
|
2240
|
+
deviceId: z13.string().optional().describe("Target device id (auto-selected if one is connected)."),
|
|
2241
|
+
key: z13.string().describe("Origin key from get_js_thread_top's JSON rows.")
|
|
2242
|
+
}
|
|
2243
|
+
},
|
|
2244
|
+
async ({ deviceId, key }) => {
|
|
2245
|
+
try {
|
|
2246
|
+
const target = sync.requireProDevice(deviceId);
|
|
2247
|
+
const detail = await sync.callAction(
|
|
2248
|
+
JS_TOP_TOOL_ID,
|
|
2249
|
+
"getOriginDetail",
|
|
2250
|
+
{ key },
|
|
2251
|
+
target
|
|
2252
|
+
);
|
|
2253
|
+
if (!detail) {
|
|
2254
|
+
return fail13(
|
|
2255
|
+
`Origin "${key}" is not tracked (cleared, evicted, or never existed). Re-run get_js_thread_top for current keys.`
|
|
2256
|
+
);
|
|
2257
|
+
}
|
|
2258
|
+
const md = formatDetailMarkdown(detail);
|
|
2259
|
+
return text11(`${md}
|
|
2260
|
+
|
|
2261
|
+
\`\`\`json
|
|
2262
|
+
${JSON.stringify(detail)}
|
|
2263
|
+
\`\`\``);
|
|
2264
|
+
} catch (e) {
|
|
2265
|
+
return fail13(errMsg13(e));
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
);
|
|
2269
|
+
server.registerTool(
|
|
2270
|
+
"clear_js_thread_stats",
|
|
2271
|
+
{
|
|
2272
|
+
description: "Reset the js-top task table, freeze history, and busy history on the device (fresh measurement window).",
|
|
2273
|
+
inputSchema: {
|
|
2274
|
+
deviceId: z13.string().optional().describe("Target device id (auto-selected if one is connected).")
|
|
2275
|
+
}
|
|
2276
|
+
},
|
|
2277
|
+
async ({ deviceId }) => {
|
|
2278
|
+
try {
|
|
2279
|
+
const target = sync.requireProDevice(deviceId);
|
|
2280
|
+
await sync.callAction(JS_TOP_TOOL_ID, "clear", {}, target);
|
|
2281
|
+
return text11("Cleared js-top stats.");
|
|
2282
|
+
} catch (e) {
|
|
2283
|
+
return fail13(errMsg13(e));
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/tools/packs/images.ts
|
|
2290
|
+
import { z as z14 } from "zod";
|
|
2291
|
+
var IMAGES_TOOL_ID = "images";
|
|
2292
|
+
var text12 = (s) => ({ content: [{ type: "text", text: s }] });
|
|
2293
|
+
var fail14 = (message) => ({ isError: true, content: [{ type: "text", text: message }] });
|
|
2294
|
+
var errMsg14 = (e) => e instanceof Error ? e.message : String(e);
|
|
2295
|
+
function uriTail(uri) {
|
|
2296
|
+
if (!uri) return "(no source)";
|
|
2297
|
+
const unstable = uri.match(/[?&]unstable_path=([^&?]+)/);
|
|
2298
|
+
const effective = unstable ? decodeURIComponent(unstable[1]) : uri;
|
|
2299
|
+
const noQuery = effective.split("?")[0] ?? effective;
|
|
2300
|
+
const segs = noQuery.split("/").filter(Boolean);
|
|
2301
|
+
return segs[segs.length - 1] ?? effective;
|
|
2302
|
+
}
|
|
2303
|
+
function verdictLabel(r) {
|
|
2304
|
+
if (r.oversizeFactor == null) return "\u2013";
|
|
2305
|
+
if (r.oversizeFactor < 0.9) return `${r.oversizeFactor.toFixed(1)}\xD7 upscaled`;
|
|
2306
|
+
if (r.oversizeFactor <= 1.1) return "right-sized";
|
|
2307
|
+
return `${r.oversizeFactor.toFixed(1)}\xD7 oversized (${r.wastedKB}KB wasted)`;
|
|
2308
|
+
}
|
|
2309
|
+
function cacheLabel(r) {
|
|
2310
|
+
if (r.cache) return r.cache;
|
|
2311
|
+
if (r.progressSeen) return "network";
|
|
2312
|
+
return "?";
|
|
2313
|
+
}
|
|
2314
|
+
function mb(bytes) {
|
|
2315
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
2316
|
+
}
|
|
2317
|
+
function formatListMarkdown(list, limit) {
|
|
2318
|
+
const lines = [];
|
|
2319
|
+
const s = list.stats;
|
|
2320
|
+
lines.push(`## Images registry (${list.total} records, showing ${Math.min(limit, list.records.length)})`);
|
|
2321
|
+
lines.push("");
|
|
2322
|
+
lines.push(
|
|
2323
|
+
`${s.loaded} loaded \xB7 ${s.errors} failed \xB7 ${s.loading} in flight \xB7 \u2248${mb(s.estDecodedBytes)} decoded` + (s.estWastedBytes > 0 ? ` \xB7 **${mb(s.estWastedBytes)} wasted on oversized sources**` : "")
|
|
2324
|
+
);
|
|
2325
|
+
const ins = list.insights;
|
|
2326
|
+
if (ins) {
|
|
2327
|
+
const chips = [];
|
|
2328
|
+
if (ins.duplicates.length > 0)
|
|
2329
|
+
chips.push(`${ins.duplicates.length} duplicate URL(s): ${ins.duplicates.slice(0, 3).map((d) => `${uriTail(d.uri)}\xD7${d.count}`).join(", ")}`);
|
|
2330
|
+
if (ins.retryStorms.length > 0)
|
|
2331
|
+
chips.push(`${ins.retryStorms.length} retry storm(s): ${ins.retryStorms.slice(0, 3).map((r) => `#${r.id}(${r.loadCount} loads)`).join(", ")}`);
|
|
2332
|
+
if (ins.queueSaturated) chips.push("iOS image queue saturated (>4 concurrent loads)");
|
|
2333
|
+
if (ins.missingAlt > 0) chips.push(`${ins.missingAlt} image(s) missing alt/accessibilityLabel`);
|
|
2334
|
+
if (ins.layoutShifters.length > 0)
|
|
2335
|
+
chips.push(`${ins.layoutShifters.length} layout-shifting image(s): ${ins.layoutShifters.slice(0, 3).map((l) => `#${l.id}`).join(", ")} \u2014 reserve explicit dimensions`);
|
|
2336
|
+
if (chips.length > 0) {
|
|
2337
|
+
lines.push("");
|
|
2338
|
+
lines.push(`**Insights:** ${chips.join(" \xB7 ")}`);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
const cs = list.captureStatus;
|
|
2342
|
+
if (cs.rnDecoratorTooLate) {
|
|
2343
|
+
lines.push("");
|
|
2344
|
+
lines.push('\u26A0 RN <Image> capture missed the module-load race \u2014 add `import "@buoy-gg/images/register"` as the FIRST import of the app entry file.');
|
|
2345
|
+
}
|
|
2346
|
+
if (list.globalModes.network !== "normal" || list.globalModes.blank) {
|
|
2347
|
+
lines.push("");
|
|
2348
|
+
lines.push(`\u26A0 Simulation active: network=${list.globalModes.network}${list.globalModes.blank ? ", blank images ON" : ""} (set_image_simulation to reset).`);
|
|
2349
|
+
}
|
|
2350
|
+
lines.push("");
|
|
2351
|
+
lines.push("| id | image | lib | status | cache | ms | decoded px | shown dp | verdict | sim |");
|
|
2352
|
+
lines.push("|---|---|---|---|---|---|---|---|---|---|");
|
|
2353
|
+
for (const r of list.records) {
|
|
2354
|
+
const dims = r.intrinsic ? `${r.intrinsic.width}\xD7${r.intrinsic.height}` : "\u2013";
|
|
2355
|
+
const layout = r.layout ? `${r.layout.width}\xD7${r.layout.height}` : "\u2013";
|
|
2356
|
+
const status = r.status === "error" ? `**error**${r.errorCode ? ` (${r.errorCode})` : ""}` : r.status;
|
|
2357
|
+
lines.push(
|
|
2358
|
+
`| ${r.id} | ${uriTail(r.uri)} | ${r.lib} | ${status}${r.mounted ? "" : " (unmounted)"} | ${cacheLabel(r)} | ${r.ms ?? "\u2013"} | ${dims} | ${layout} | ${verdictLabel(r)} | ${r.overrideLabel ?? "\u2013"} |`
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
lines.push("");
|
|
2362
|
+
lines.push("_Act on a row with `image_action` using its `id` (hard_reload / retry / force_error / force_loading / force_blank / swap_url / flash / restore / evict_disk)._");
|
|
2363
|
+
return lines.join("\n");
|
|
2364
|
+
}
|
|
2365
|
+
var ACTION_MAP = {
|
|
2366
|
+
hard_reload: { action: "hardReload" },
|
|
2367
|
+
retry: { action: "retry" },
|
|
2368
|
+
evict_disk: { action: "evictDisk" },
|
|
2369
|
+
flash: { action: "flash" },
|
|
2370
|
+
restore: { action: "clearOverride" },
|
|
2371
|
+
force_error: { action: "setOverride", params: () => ({ kind: "error" }) },
|
|
2372
|
+
force_loading: { action: "setOverride", params: () => ({ kind: "hang" }) },
|
|
2373
|
+
force_blank: { action: "setOverride", params: () => ({ kind: "blank" }) },
|
|
2374
|
+
swap_url: { action: "setOverride", params: (url) => ({ kind: "url", uri: url }) }
|
|
2375
|
+
};
|
|
2376
|
+
function registerImagesTools(server, sync) {
|
|
2377
|
+
server.registerTool(
|
|
2378
|
+
"get_images",
|
|
2379
|
+
{
|
|
2380
|
+
description: "Live image registry from the app: every RN <Image> and expo-image load with cache verdict (memory/disk/network), load ms, decoded vs displayed size with an oversize/upscale verdict and wasted-KB estimate, error details, and any active simulation overrides. Image HTTP never hits the JS network stack, so this is the only visibility into image loading. Requires @buoy-gg/images on the device.",
|
|
2381
|
+
inputSchema: {
|
|
2382
|
+
deviceId: z14.string().optional().describe("Target device id (auto-selected if one is connected)."),
|
|
2383
|
+
limit: z14.number().optional().describe("Max records to return (newest first). Default 30, max 200."),
|
|
2384
|
+
status: z14.enum(["pending", "loading", "loaded", "error"]).optional().describe("Only records in this state (e.g. 'error' for the failure log).")
|
|
2385
|
+
}
|
|
2386
|
+
},
|
|
2387
|
+
async ({ deviceId, limit, status }) => {
|
|
2388
|
+
try {
|
|
2389
|
+
const target = sync.requireProDevice(deviceId);
|
|
2390
|
+
const capped = Math.min(Math.max(limit ?? 30, 1), 200);
|
|
2391
|
+
const list = await sync.callAction(
|
|
2392
|
+
IMAGES_TOOL_ID,
|
|
2393
|
+
"list",
|
|
2394
|
+
{ limit: capped, status },
|
|
2395
|
+
target
|
|
2396
|
+
);
|
|
2397
|
+
if (!list) return fail14("Device returned nothing \u2014 is @buoy-gg/images installed in the app?");
|
|
2398
|
+
const md = formatListMarkdown(list, capped);
|
|
2399
|
+
const slim = {
|
|
2400
|
+
stats: list.stats,
|
|
2401
|
+
insights: list.insights,
|
|
2402
|
+
captureStatus: list.captureStatus,
|
|
2403
|
+
globalModes: list.globalModes,
|
|
2404
|
+
records: list.records.map((r) => ({
|
|
2405
|
+
id: r.id,
|
|
2406
|
+
uri: r.uri,
|
|
2407
|
+
lib: r.lib,
|
|
2408
|
+
status: r.status,
|
|
2409
|
+
mounted: r.mounted,
|
|
2410
|
+
cache: r.cache,
|
|
2411
|
+
ms: r.ms,
|
|
2412
|
+
intrinsic: r.intrinsic,
|
|
2413
|
+
layout: r.layout,
|
|
2414
|
+
oversizeFactor: r.oversizeFactor,
|
|
2415
|
+
wastedKB: r.wastedKB,
|
|
2416
|
+
error: r.error,
|
|
2417
|
+
errorCode: r.errorCode,
|
|
2418
|
+
overrideLabel: r.overrideLabel,
|
|
2419
|
+
loadCount: r.loadCount
|
|
2420
|
+
}))
|
|
2421
|
+
};
|
|
2422
|
+
return text12(`${md}
|
|
2423
|
+
|
|
2424
|
+
\`\`\`json
|
|
2425
|
+
${JSON.stringify(slim)}
|
|
2426
|
+
\`\`\``);
|
|
2427
|
+
} catch (e) {
|
|
2428
|
+
return fail14(errMsg14(e));
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
);
|
|
2432
|
+
server.registerTool(
|
|
2433
|
+
"image_action",
|
|
2434
|
+
{
|
|
2435
|
+
description: "Act on one image from get_images (by id): hard_reload (bypass caches + refetch), retry, evict_disk (expo disk entry), flash (red border to locate it on screen), prove_savings (re-encode at displayed size as WebP ON DEVICE and return real byte savings + a previewable file:// uri), or simulation overrides \u2014 force_error (instant offline-safe failure), force_loading (request hangs forever), force_blank, swap_url (requires url), restore (remove override). Returns the record's state after the action so the effect is visible.",
|
|
2436
|
+
inputSchema: {
|
|
2437
|
+
deviceId: z14.string().optional().describe("Target device id (auto-selected if one is connected)."),
|
|
2438
|
+
id: z14.number().describe("Record id from get_images."),
|
|
2439
|
+
action: z14.enum([
|
|
2440
|
+
"hard_reload",
|
|
2441
|
+
"retry",
|
|
2442
|
+
"evict_disk",
|
|
2443
|
+
"flash",
|
|
2444
|
+
"prove_savings",
|
|
2445
|
+
"force_error",
|
|
2446
|
+
"force_loading",
|
|
2447
|
+
"force_blank",
|
|
2448
|
+
"swap_url",
|
|
2449
|
+
"restore"
|
|
2450
|
+
]).describe("What to do."),
|
|
2451
|
+
url: z14.string().optional().describe("Replacement URL (swap_url only).")
|
|
2452
|
+
}
|
|
2453
|
+
},
|
|
2454
|
+
async ({ deviceId, id, action, url }) => {
|
|
2455
|
+
try {
|
|
2456
|
+
const target = sync.requireProDevice(deviceId);
|
|
2457
|
+
if (action === "prove_savings") {
|
|
2458
|
+
const savings = await sync.callAction(IMAGES_TOOL_ID, "proveSavings", { id }, target);
|
|
2459
|
+
if (!savings) return fail14("Device returned nothing for proveSavings.");
|
|
2460
|
+
if (!savings.ok) return fail14(savings.message);
|
|
2461
|
+
const parts = [`**prove_savings** on #${id}: ${savings.message}`];
|
|
2462
|
+
if (savings.originalBytes != null)
|
|
2463
|
+
parts.push(`Original: ${Math.round(savings.originalBytes / 1024)}KB (${savings.originalBytesSource})`);
|
|
2464
|
+
if (savings.optimizedBytes != null)
|
|
2465
|
+
parts.push(
|
|
2466
|
+
`Optimized: ${Math.round(savings.optimizedBytes / 1024)}KB \u2014 ${savings.optimizedDims?.width}\xD7${savings.optimizedDims?.height} WebP`
|
|
2467
|
+
);
|
|
2468
|
+
if (savings.savedBytes != null)
|
|
2469
|
+
parts.push(`**Saved: ${Math.round(savings.savedBytes / 1024)}KB (${savings.savedPct}%)**`);
|
|
2470
|
+
if (savings.optimizedUri)
|
|
2471
|
+
parts.push(`Preview in place: image_action {id:${id}, action:"swap_url", url:"${savings.optimizedUri}"}`);
|
|
2472
|
+
return text12(parts.join("\n"));
|
|
2473
|
+
}
|
|
2474
|
+
const entry = ACTION_MAP[action];
|
|
2475
|
+
if (!entry) return fail14(`Unknown action: ${action}`);
|
|
2476
|
+
if (action === "swap_url" && !url) return fail14("swap_url requires the `url` param.");
|
|
2477
|
+
const result = await sync.callAction(
|
|
2478
|
+
IMAGES_TOOL_ID,
|
|
2479
|
+
entry.action,
|
|
2480
|
+
{ id, ...entry.params?.(url) ?? {} },
|
|
2481
|
+
target
|
|
2482
|
+
);
|
|
2483
|
+
const settleMs = action === "flash" ? 0 : 1500;
|
|
2484
|
+
if (settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
|
|
2485
|
+
const detail = await sync.callAction(IMAGES_TOOL_ID, "getDetail", { id }, target).catch(() => null);
|
|
2486
|
+
const lines = [
|
|
2487
|
+
`**${action}** on #${id}: ${result?.message ?? (result?.ok === false ? "failed" : "done")}`
|
|
2488
|
+
];
|
|
2489
|
+
if (detail) {
|
|
2490
|
+
lines.push("");
|
|
2491
|
+
lines.push(
|
|
2492
|
+
`Now: status=${detail.status}${detail.errorCode ? ` (${detail.errorCode})` : ""} \xB7 cache=${cacheLabel(detail)} \xB7 ms=${detail.ms ?? "\u2013"} \xB7 loads=${detail.loadCount}${detail.overrideLabel ? ` \xB7 sim="${detail.overrideLabel}"` : ""}${detail.mounted ? "" : " \xB7 UNMOUNTED"}`
|
|
2493
|
+
);
|
|
2494
|
+
if (detail.error) lines.push(`Error: ${detail.error}`);
|
|
2495
|
+
}
|
|
2496
|
+
return text12(lines.join("\n"));
|
|
2497
|
+
} catch (e) {
|
|
2498
|
+
return fail14(errMsg14(e));
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
);
|
|
2502
|
+
server.registerTool(
|
|
2503
|
+
"set_image_simulation",
|
|
2504
|
+
{
|
|
2505
|
+
description: "App-wide image simulation modes: network 'offline' (every NETWORK image load fails immediately, both libs \u2014 bundled assets still load; reload/remount on-screen images to see it), 'cold' (every image bypasses caches \u2014 first-launch behavior, nothing cleared), 'normal' to reset. blankImages renders every image without a source. massAction applies per-image simulations to everything mounted. Also clears the registry or expo caches when asked.",
|
|
2506
|
+
inputSchema: {
|
|
2507
|
+
deviceId: z14.string().optional().describe("Target device id (auto-selected if one is connected)."),
|
|
2508
|
+
network: z14.enum(["normal", "offline", "cold"]).optional().describe("Network simulation mode."),
|
|
2509
|
+
blankImages: z14.boolean().optional().describe("Render all images without a source."),
|
|
2510
|
+
massAction: z14.enum(["error", "loading", "blank", "reload", "flash", "restore"]).optional().describe("Apply to EVERY mounted image at once: force error/loading/blank overrides, hard-reload all, flash all, or restore all overrides."),
|
|
2511
|
+
clearRegistry: z14.boolean().optional().describe("Clear the captured image log."),
|
|
2512
|
+
clearExpoCaches: z14.boolean().optional().describe("Clear expo-image memory + disk caches.")
|
|
2513
|
+
}
|
|
2514
|
+
},
|
|
2515
|
+
async ({ deviceId, network, blankImages, massAction, clearRegistry, clearExpoCaches }) => {
|
|
2516
|
+
try {
|
|
2517
|
+
const target = sync.requireProDevice(deviceId);
|
|
2518
|
+
const done = [];
|
|
2519
|
+
if (massAction) {
|
|
2520
|
+
const r = await sync.callAction(IMAGES_TOOL_ID, "massAction", { kind: massAction }, target);
|
|
2521
|
+
done.push(r?.message ?? `mass ${massAction} applied`);
|
|
2522
|
+
}
|
|
2523
|
+
if (network) {
|
|
2524
|
+
await sync.callAction(IMAGES_TOOL_ID, "setNetworkMode", { mode: network }, target);
|
|
2525
|
+
done.push(`network=${network}`);
|
|
2526
|
+
}
|
|
2527
|
+
if (blankImages != null) {
|
|
2528
|
+
await sync.callAction(IMAGES_TOOL_ID, "setBlankImages", { enabled: blankImages }, target);
|
|
2529
|
+
done.push(`blankImages=${blankImages}`);
|
|
2530
|
+
}
|
|
2531
|
+
if (clearRegistry) {
|
|
2532
|
+
await sync.callAction(IMAGES_TOOL_ID, "clearRecords", {}, target);
|
|
2533
|
+
done.push("registry cleared");
|
|
2534
|
+
}
|
|
2535
|
+
if (clearExpoCaches) {
|
|
2536
|
+
const r = await sync.callAction(IMAGES_TOOL_ID, "clearExpoCaches", {}, target);
|
|
2537
|
+
done.push(r?.message ?? "expo caches cleared");
|
|
2538
|
+
}
|
|
2539
|
+
if (done.length === 0) return fail14("Pass at least one of: network, blankImages, massAction, clearRegistry, clearExpoCaches.");
|
|
2540
|
+
return text12(`Applied: ${done.join(" \xB7 ")}`);
|
|
2541
|
+
} catch (e) {
|
|
2542
|
+
return fail14(errMsg14(e));
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2115
2548
|
// src/server.ts
|
|
2116
2549
|
var SERVER_INSTRUCTIONS = `Buoy MCP \u2014 drive Buoy dev tools running in a React Native app from your editor.
|
|
2117
2550
|
|
|
@@ -2134,6 +2567,15 @@ elements (accessibility-style tree with tap points + testIDs, no screenshot), th
|
|
|
2134
2567
|
({ testID | query | nativeTag }) to tap one \u2014 it invokes the element's onPress directly in JS, so
|
|
2135
2568
|
it's fast and works on physical devices too. Re-run \`describe_screen\` after the screen changes.
|
|
2136
2569
|
|
|
2570
|
+
JS FPS low / app hitching and you need to know WHAT is eating the JS thread? Use
|
|
2571
|
+
\`get_js_thread_top\` (a live "Task Manager" of timers, Promise chains, rAF callbacks and
|
|
2572
|
+
freezes, ranked by thread time), then \`get_js_thread_origin_detail\` to drill into a row.
|
|
2573
|
+
Pass clearFirst:true and drive the UI with \`tap_element\` mid-sample to profile one interaction.
|
|
2574
|
+
|
|
2575
|
+
Debugging images (not loading, slow, stale cache, memory)? \`get_images\` lists every RN <Image> /
|
|
2576
|
+
expo-image load with cache verdicts and oversize audits; \`image_action\` reloads, retries, or
|
|
2577
|
+
simulates failures per image; \`set_image_simulation\` flips app-wide offline/cold/blank modes.
|
|
2578
|
+
|
|
2137
2579
|
For everything else:
|
|
2138
2580
|
- \`list_tool_actions\` to see what a tool can do,
|
|
2139
2581
|
- \`get_snapshot\` ONLY when you need a tool's full current state (returns raw, heavy payloads),
|
|
@@ -2162,22 +2604,28 @@ function createMcpServer(sync) {
|
|
|
2162
2604
|
registerConsoleTools(server, sync);
|
|
2163
2605
|
registerScreenshotTools(server, sync);
|
|
2164
2606
|
registerInteractTools(server, sync);
|
|
2607
|
+
registerJsThreadTools(server, sync);
|
|
2608
|
+
registerImagesTools(server, sync);
|
|
2165
2609
|
return server;
|
|
2166
2610
|
}
|
|
2167
2611
|
var deviceIdField = {
|
|
2168
|
-
deviceId:
|
|
2612
|
+
deviceId: z15.string().optional().describe(
|
|
2169
2613
|
'Target device id (from list_devices). Omit to auto-select when exactly one device is connected. Use "All" to broadcast.'
|
|
2170
2614
|
)
|
|
2171
2615
|
};
|
|
2172
2616
|
|
|
2173
2617
|
// src/init.ts
|
|
2618
|
+
import { spawnSync } from "child_process";
|
|
2174
2619
|
import fs3 from "fs";
|
|
2620
|
+
import os3 from "os";
|
|
2175
2621
|
import path3 from "path";
|
|
2622
|
+
var PUBLIC_REGISTRY = "https://registry.npmjs.org";
|
|
2623
|
+
var PROBE_TIMEOUT_MS = 3e3;
|
|
2176
2624
|
function runInit(opts = {}) {
|
|
2177
2625
|
const cwd = opts.cwd ?? process.cwd();
|
|
2178
2626
|
const skillSrcDir = opts.skillSrcDir ?? path3.resolve(__dirname, "../skill");
|
|
2179
2627
|
const log = opts.log ?? ((m) => process.stdout.write(m + "\n"));
|
|
2180
|
-
const entry = opts
|
|
2628
|
+
const entry = resolveEntry(opts, log);
|
|
2181
2629
|
const done = [];
|
|
2182
2630
|
upsertMcpConfig(path3.join(cwd, ".mcp.json"), "mcpServers", entry, cwd, done);
|
|
2183
2631
|
if (fs3.existsSync(path3.join(cwd, ".cursor"))) {
|
|
@@ -2194,12 +2642,125 @@ function runInit(opts = {}) {
|
|
|
2194
2642
|
log(" 1. Restart your editor (or reconnect the MCP server) to load `buoy`.");
|
|
2195
2643
|
log(" 2. Open your app with Buoy devtools running on a device or simulator.");
|
|
2196
2644
|
log(' 3. Say "buoy optimize" to start a guided performance pass.');
|
|
2197
|
-
if (
|
|
2645
|
+
if (entry.command === "npx") {
|
|
2198
2646
|
log("");
|
|
2199
2647
|
log(`This launches the server via \`npx -y ${PACKAGE_NAME}@latest\`, so it stays`);
|
|
2200
2648
|
log(`up to date automatically. To force-refresh, re-run \`npx -y ${PACKAGE_NAME}@latest init\`.`);
|
|
2649
|
+
} else if (!opts.cliPath) {
|
|
2650
|
+
log("");
|
|
2651
|
+
log(`This uses a pinned local install (no network on startup). To update it later,`);
|
|
2652
|
+
log(`re-run init (\`node ${entry.args[0]?.replace(/dist[/\\]cli\.js$/, "") ?? ""}...\` \u2014 or reinstall).`);
|
|
2201
2653
|
}
|
|
2202
2654
|
}
|
|
2655
|
+
function resolveEntry(opts, log) {
|
|
2656
|
+
if (opts.cliPath) {
|
|
2657
|
+
return { type: "stdio", command: "node", args: [opts.cliPath] };
|
|
2658
|
+
}
|
|
2659
|
+
const mode = opts.mode ?? "auto";
|
|
2660
|
+
if (mode === "npx") {
|
|
2661
|
+
return npxEntry();
|
|
2662
|
+
}
|
|
2663
|
+
if (mode === "local") {
|
|
2664
|
+
return localEntry(opts, log);
|
|
2665
|
+
}
|
|
2666
|
+
const probe = probeRegistry();
|
|
2667
|
+
if (probe.reachable) {
|
|
2668
|
+
return npxEntry();
|
|
2669
|
+
}
|
|
2670
|
+
log(
|
|
2671
|
+
`\u26A0 npm registry ${probe.registry ? `(${probe.registry}) ` : ""}looks unreachable \u2014 \`npx @latest\` would hang your editor at startup. Falling back to a pinned local install.`
|
|
2672
|
+
);
|
|
2673
|
+
return localEntry(opts, log);
|
|
2674
|
+
}
|
|
2675
|
+
function npxEntry() {
|
|
2676
|
+
return { type: "stdio", command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
|
|
2677
|
+
}
|
|
2678
|
+
function localEntry(opts, log) {
|
|
2679
|
+
const installDir = opts.installDir ?? process.env.BUOY_MCP_HOME ?? path3.join(os3.homedir(), ".buoy-mcp");
|
|
2680
|
+
const registry = opts.registry ?? PUBLIC_REGISTRY;
|
|
2681
|
+
const cliPath = installLocalCopy(installDir, registry, log);
|
|
2682
|
+
if (cliPath) {
|
|
2683
|
+
return { type: "stdio", command: "node", args: [cliPath] };
|
|
2684
|
+
}
|
|
2685
|
+
log("\u26A0 local install failed \u2014 writing the npx entry instead (see the error above).");
|
|
2686
|
+
return npxEntry();
|
|
2687
|
+
}
|
|
2688
|
+
function installLocalCopy(installDir, registry, log) {
|
|
2689
|
+
const spec = `${PACKAGE_NAME}@${VERSION === "0.0.0-dev" ? "latest" : VERSION}`;
|
|
2690
|
+
fs3.mkdirSync(installDir, { recursive: true });
|
|
2691
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
2692
|
+
log(`Installing ${spec} into ${installDir} from ${registry} \u2026`);
|
|
2693
|
+
const res = spawnSync(
|
|
2694
|
+
npm,
|
|
2695
|
+
[
|
|
2696
|
+
"install",
|
|
2697
|
+
spec,
|
|
2698
|
+
"--prefix",
|
|
2699
|
+
installDir,
|
|
2700
|
+
"--registry",
|
|
2701
|
+
registry,
|
|
2702
|
+
`--@buoy-gg:registry=${registry}`,
|
|
2703
|
+
"--no-audit",
|
|
2704
|
+
"--no-fund",
|
|
2705
|
+
"--loglevel=error"
|
|
2706
|
+
],
|
|
2707
|
+
{ stdio: ["ignore", "ignore", "inherit"], encoding: "utf8" }
|
|
2708
|
+
);
|
|
2709
|
+
if (res.status !== 0) {
|
|
2710
|
+
log(`\u26A0 npm install exited with code ${res.status ?? "?"}${res.error ? ` (${res.error.message})` : ""}.`);
|
|
2711
|
+
return null;
|
|
2712
|
+
}
|
|
2713
|
+
const cliPath = path3.join(installDir, "node_modules", PACKAGE_NAME, "dist", "cli.js");
|
|
2714
|
+
if (!fs3.existsSync(cliPath)) {
|
|
2715
|
+
log(`\u26A0 install finished but ${cliPath} is missing.`);
|
|
2716
|
+
return null;
|
|
2717
|
+
}
|
|
2718
|
+
return cliPath;
|
|
2719
|
+
}
|
|
2720
|
+
function probeRegistry() {
|
|
2721
|
+
const registry = effectiveRegistry();
|
|
2722
|
+
if (!registry) return { reachable: false, registry: null };
|
|
2723
|
+
if (/^https?:\/\/registry\.npmjs\.org\/?$/i.test(registry)) {
|
|
2724
|
+
return { reachable: true, registry };
|
|
2725
|
+
}
|
|
2726
|
+
return { reachable: hostReachable(registry), registry };
|
|
2727
|
+
}
|
|
2728
|
+
function effectiveRegistry() {
|
|
2729
|
+
const scoped = npmConfigGet("@buoy-gg:registry");
|
|
2730
|
+
if (scoped) return scoped;
|
|
2731
|
+
const global = npmConfigGet("registry");
|
|
2732
|
+
return global ?? PUBLIC_REGISTRY;
|
|
2733
|
+
}
|
|
2734
|
+
function npmConfigGet(key) {
|
|
2735
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
2736
|
+
const res = spawnSync(npm, ["config", "get", key], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS });
|
|
2737
|
+
if (res.status !== 0 || typeof res.stdout !== "string") return null;
|
|
2738
|
+
const val = res.stdout.trim();
|
|
2739
|
+
if (!val || val === "undefined" || val === "null") return null;
|
|
2740
|
+
return val;
|
|
2741
|
+
}
|
|
2742
|
+
function hostReachable(registry) {
|
|
2743
|
+
const done = spawnSync(
|
|
2744
|
+
process.execPath,
|
|
2745
|
+
["-e", REACHABILITY_PROBE, registry, String(PROBE_TIMEOUT_MS)],
|
|
2746
|
+
{ encoding: "utf8", timeout: PROBE_TIMEOUT_MS + 2e3 }
|
|
2747
|
+
);
|
|
2748
|
+
return done.status === 0;
|
|
2749
|
+
}
|
|
2750
|
+
var REACHABILITY_PROBE = `
|
|
2751
|
+
const url = process.argv[1];
|
|
2752
|
+
const ms = Number(process.argv[2]) || 3000;
|
|
2753
|
+
const c = new AbortController();
|
|
2754
|
+
const t = setTimeout(() => c.abort(), ms);
|
|
2755
|
+
fetch(url, { method: 'HEAD', signal: c.signal })
|
|
2756
|
+
.then(() => { clearTimeout(t); process.exit(0); })
|
|
2757
|
+
.catch(() => {
|
|
2758
|
+
// A HEAD may be rejected while the host is up; a bare TCP-level failure is
|
|
2759
|
+
// what we care about. fetch throwing a TypeError = network unreachable.
|
|
2760
|
+
clearTimeout(t);
|
|
2761
|
+
process.exit(1);
|
|
2762
|
+
});
|
|
2763
|
+
`;
|
|
2203
2764
|
function upsertMcpConfig(file, key, entry, cwd, done) {
|
|
2204
2765
|
let json3 = {};
|
|
2205
2766
|
if (fs3.existsSync(file)) {
|