// The MCP bridge: a stdio Model Context Protocol server exposing the dev // server's control API (/__control__/) as tools for coding agents. Stateless // glue: every tool call is one HTTP request to the running dev server, so the // bridge works no matter which process (or how many agents) spawned it. // // stdout is the JSON-RPC channel; nothing here may print to it. import { z } from "zod" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import type { CallToolResult, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js" import { resolve } from "node:path" import { port as FIXED_PORT } from "../lib/args" import { CLI_VERSION } from "../lib/project" import { resolveFromCwd, sameKey } from "../lib/registry" import type { ImageResponse } from "../types/control" // An explicit --port pins the port for the bridge's lifetime. Otherwise the // server is resolved from the registry by the bridge's cwd (once, then again // whenever the server it found stops serving that key - see control()), so // one bridge (started when the workspace opens, kept alive across server // restarts) follows whichever server is currently serving this project - and // the scaffold's mcp.json never carries a port. // A pinned port carries no key: the user chose it, so nothing is checked. type PortResult = { ok: true; port: number; key: string | null } | { ok: false; message: string } async function resolvePort(): Promise { if (FIXED_PORT !== undefined) return { ok: true, port: FIXED_PORT, key: null } let resolved = await resolveFromCwd(process.cwd()) if (!resolved.ok) return resolved // The record is a hint; the server is authoritative. A stale record (a pid // reused by an unrelated process, a port taken over by another server) // shows on the first call: every control response names the key it // serves, and control() checks it. return { ok: true, port: resolved.record.port, key: resolved.record.key } } // `note` rides along with a good answer when the bridge has something the // body does not say (see control()); errors fold it into the message. type ControlResult = { ok: true; body: any; note?: string } | { ok: false; message: string } // The resolved server, kept across calls: a server's port never changes while // it runs, so the registry is read once and again only when the port stops // answering or answers for another key (a server that died can be replaced // by one serving something else on the port it remembered). Each response's // x-solidrt-project header is that check, so a takeover shows on the next // call at no extra cost. let cached: { port: number; key: string | null } | null = null // The server generation seen on the previous response. A restart invalidates // every id and cursor the agent holds; comparing generations on each call // makes that visible at once instead of as a puzzling "client gone". let lastGeneration: string | null = null const RESTART_NOTE = "Note: the dev server restarted since your last call. Client ids, node ids and log cursors from before are stale; re-fetch them (list_clients, get_render_tree, get_logs from since 0)." async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise { for (let attempt = 0; ; attempt++) { if (!cached) { let resolved = await resolvePort() if (!resolved.ok) return resolved cached = { port: resolved.port, key: resolved.key } } let { port, key } = cached let resp try { let init: RequestInit = { method } if (payload !== undefined) { init.headers = { "content-type": "application/json" } init.body = JSON.stringify(payload) } resp = await fetch(`http://127.0.0.1:${port}/__control__${path}`, init) } catch { cached = null if (attempt === 0) continue return { ok: false, message: `No dev server answers on port ${port}${key ? ` for ${key}` : ""}. Start one with sol run.`, } } let served = resp.headers.get("x-solidrt-project") if (key !== null && (served === null || !sameKey(served, key))) { cached = null if (attempt === 0) continue return { ok: false, message: `The server on port ${port} is not serving ${key}${served ? ` (it serves ${served})` : ""}. Start one with sol run, or pass --port to sol mcp.`, } } let note: string | undefined let generation = resp.headers.get("x-solidrt-generation") if (generation !== null) { if (lastGeneration !== null && generation !== lastGeneration) note = RESTART_NOTE lastGeneration = generation } let body: any = null try { body = await resp.json() } catch {} if (!resp.ok) { let message = String(body?.error ?? `Dev server responded with HTTP ${resp.status}`) return { ok: false, message: note ? `${message} ${note}` : message } } return { ok: true, body, note } } } let CLIENT_ARG = z .number() .int() .describe("Client id from list_clients (default: the only connected client; required when several are connected)") .optional() let SAVE_TO_ARG = z .string() .describe( "Also write the PNG to this file path (relative paths resolve against the project root; parent directories are created)", ) .optional() // One synthetic input event, per type. The runtime rejects the whole // sequence on one bad event, so the schema states each type's required // fields up front: a pointer needs its action and position, a key its // action and name, a wheel its position and both deltas, text its text. // holdMs is a tap's down-to-up time and is refused on any other action. let DELAY_ARG = z.number().int().min(0).max(5000).optional().describe("Wait before this event, ms") let HOLD_ARG = z.number().int().min(0).max(5000).optional().describe("Tap only: time between down and up, ms") let MODIFIER_ARGS = { shift: z.boolean().optional(), ctrl: z.boolean().optional(), alt: z.boolean().optional(), meta: z.boolean().optional(), } let EVENT_ARG = z.discriminatedUnion("type", [ z.object({ type: z.literal("pointer"), action: z.enum(["down", "up", "cancel", "move", "tap", "drag"]), x: z.number().describe("Logical points, the space get_render_tree reports"), y: z.number(), to: z .object({ x: z.number(), y: z.number() }) .optional() .describe("drag only: where the drag ends"), durationMs: z.number().int().min(0).max(5000).optional().describe("drag only: how long the drag takes (default 300)"), button: z.number().int().min(0).max(4).optional().describe("0 = left (default), 1 = middle, 2 = right"), pointerType: z.enum(["mouse", "touch"]).optional().describe("mouse (default) keeps hovering afterwards; touch ends hover-free"), holdMs: HOLD_ARG, delayMs: DELAY_ARG, ...MODIFIER_ARGS, }), z.object({ type: z.literal("key"), action: z.enum(["down", "up", "tap"]), key: z.string().min(1).describe("W3C key name as the runtime reports it ('w', 'ArrowLeft', 'Enter', ' ')"), holdMs: HOLD_ARG, delayMs: DELAY_ARG, ...MODIFIER_ARGS, }), z.object({ type: z.literal("wheel"), x: z.number().describe("Logical points"), y: z.number(), deltaX: z.number(), deltaY: z.number().describe("Positive scrolls content down"), delayMs: DELAY_ARG, ...MODIFIER_ARGS, }), z.object({ type: z.literal("text"), text: z.string().min(1).describe("Entered through the TextInput path; focus the target first"), delayMs: DELAY_ARG, }), z.object({ type: z.literal("back"), delayMs: DELAY_ARG, }), z.object({ type: z.literal("gamepad"), action: z.enum(["connect", "set", "disconnect"]), slot: z .number() .int() .min(0) .max(15) .optional() .describe("The pad's slot: required for set and disconnect; on connect the slot to seat in (default: the lowest free one, as a physical pad takes)"), name: z.string().optional().describe("connect only: the pad's reported name (default 'Synthetic gamepad')"), buttons: z .array(z.string()) .optional() .describe( "set only: the buttons held from now on (SDL positional names: south, east, west, north, back, guide, start, leftStick, rightStick, leftShoulder, rightShoulder, dpadUp, dpadDown, dpadLeft, dpadRight); omitted = none", ), axes: z .record(z.string(), z.number()) .optional() .describe("set only: axis values held from now on (leftX, leftY, rightX, rightY in -1..1, y down; leftTrigger, rightTrigger in 0..1); omitted = all at rest"), holdMs: z.number().int().min(0).max(5000).optional().describe("set only: hold this state that long, then return the pad to rest in the same call"), delayMs: DELAY_ARG, }), ]) // MCP-standard tool annotations, so agent harnesses that honor them can // auto-approve the harmless majority. The rule: readOnlyHint on tools that // only inspect; destructiveHint: false on tools that drive the running app // but leave the code it runs untouched (input, clock, debug commands, the // holds, the overlay); idempotentHint: true where a call sets a state rather // than performing an action. reload and load replace the running app and // its state, so they keep the defaults (destructive, not idempotent). Every // tool gets openWorldHint: false - the bridge only ever talks to the local // dev server. type Annotations = { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean } let READ_ONLY: Annotations = { readOnlyHint: true } let DRIVES_APP: Annotations = { destructiveHint: false } let SETS_STATE: Annotations = { destructiveHint: false, idempotentHint: true } let TOOLS: { name: string description: string inputSchema: Record annotations?: Annotations }[] = [ { name: "list_clients", annotations: READ_ONLY, description: "List the app clients connected to the SolidRT dev server, and what the server serves. Server fields: `generation` (identity of this server run; client ids, node ids and log cursors are only valid within one, so if it changed since your last call, re-fetch them), `key` and `mode` (the project root, or the single file, this server serves - check it is the app you intend to drive before acting), `entry` (the app source file it rebuilds; `load` moves it), `projectDir` (null for a file served on its own), `userInputMuted` (see mute_user_input) and `watchPaused` (see pause_watch). Per client: `id` (pass it as `client` to the other tools), `platform`, `version` (the runtime's git describe; a -dirty suffix means it was built from uncommitted engine changes), `profile` (debug/release), `capabilities` (the capability names compiled into that runtime), `queries` (the dev-tool query kinds that runtime answers: clock, input, snapshot, tree, ...; a list without \"input\" predates send_input, one without \"clock\" predates set_time_scale/step_frames, an empty list predates the advertisement itself - check it before planning a verification strategy), `stats` (whether its overlay is drawn, see set_stats_overlay), `timeScale` (its clock as it last answered set_time_scale/step_frames: 0 paused, 1 real time; back to 1 on every reload), `loading` (true while it is still building the first frame of the last reload or load - queries to it wait for that frame within their timeout and answer 503 if it never comes; false once the frame is up; null on a runtime that predates the signal, which is queried right away), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `/client` for a dev client), `pid`, `execPath` (the runtime binary), `host` (hostname), `os` and `kernel` (the OS as a person names it, e.g. \"Android 15 on Pixel 9 Pro\", and the kernel version), `videoDriver` (SDL's: wayland, x11, android, ...), `refreshRate` (the display's nominal refresh rate in Hz as SDL reported it at connect, what `onFrame`'s `rate` argument carries; null when the client connected before its window existed, a reconnect fills it in) and `gpu` (vendor, renderer, version as GL reports them) - each null on a runtime that predates it or has no such fact. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.", inputSchema: {}, }, { name: "get_logs", annotations: READ_ONLY, description: "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text; consecutive identical entries are collapsed into one with a `repeats` count and the run's last seq, and so is a run of the runtime's once-a-second \"Slow frame\" warnings during a continuous animation, kept as its latest line, so the one real warning in between stays visible), plus `latest` (the newest seq) and `generation` (identity of this server run; if it changed since your last call, your seq cursor and client ids are stale - start over from since 0). Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload; pass `level`/`contains`/`client` to filter, e.g. level \"error\" to skip chatty output, or one client's id when several are connected (entries carry `client` either way; the seq cursor is shared by all).", inputSchema: { since: z .number() .int() .describe("Only return entries with seq greater than this (default 0: the whole buffer)") .optional(), wait_ms: z .number() .int() .min(0) .max(30000) .describe("If nothing matches newer than `since`, wait up to this many milliseconds for new output (max 30000)") .optional(), level: z .string() .describe('Only return entries with one of these levels, comma-separated (e.g. "error" or "error,warn")') .optional(), contains: z .string() .describe("Only return entries whose text contains this substring (case-insensitive)") .optional(), client: z .number() .int() .describe("Only return entries from this client id (default: every client's)") .optional(), }, }, { name: "get_stats", annotations: READ_ONLY, description: "Performance statistics from a running app client. Start with `window`: a summary of the frames that changed the picture in the last window_ms (default 5000, max 10000), or the last window_frames of them (the window to use under a frozen clock): tree rebuilds, plus GPU content changes presented without one (a layer write, a shader param, an upload: a sprite or shader app's every frame, where the critical path is the render handler alone) - frames, p50Ms/p95Ms/maxMs of the JS-thread critical path per frame (render handler + layout + postLayout + paint + hover), slowFrames (frames over the refresh period, periodMs), captureFrames (frames a get_snapshot readback stalled - tooling time, left out of the percentiles, slowFrames and worst, and marked as such in the slow-frame log line), the window's totals backdropsPrepainted and textLayers (each summed over its frames; textLayers is how often the window's frames re-rasterized text, the count a scale animation's cost shows in) and nodesPaintedMax, and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (wordShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted, backdropsPrepainted). This is where jank shows: quote the window's missedPresents (presents the display missed while a next frame was demanded - each one a refresh that repeated the old frame, the hitch the eye sees) as the primary jank figure, and the other figures as its cause (slowFrames = JS thread, gpuFrameExecMsPerFrame/fenceTimeoutsPerSec = GPU, rasterCmdMsPerSec = raster side work). It counts what everything else is blind to: a frame missing its slot on the raster thread or in GPU execution leaves the JS critical path, fps and every per-second average reading clean. Typical flow: mute_user_input, then send_input a burst (typing, a drag), then get_stats - mute first whenever you are measuring, or the human's own input is in the sample and nothing in the payload says so - `frames: 0` means nothing changed the picture in the window (idle app), which is different from all-fast. The window also carries the GPU figures when it spans 2+ frames: missedPresents (a count over the window, not a rate; idle gaps never count, only gaps while a next frame was demanded), fenceTimeoutsPerSec, gpuPassesPerFrame (per presented frame), gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame, gpuFrameExecMsPerFrame, rasterCmdMsPerSec - read these instead of differencing the cumulatives yourself. timeMs (client monotonic clock) and frame (present index) stamp the payload so two samples can be differenced; latestFrameAgeMs is how long ago that latest frame was, since the top-level per-frame figures (jsMs, layoutMs, nodesPainted, paintOps, ...) describe the LATEST frame that changed the picture, not the current moment: an app that went idle a second ago still reads its last busy frame there beside a quiet window summary. Then the smoothed figures: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), wordShapes (words actually shaped, the ones the shared word cache did not have; the expensive signal - high layoutMs with near-zero wordShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and wordShapes near zero on a text change means only the changed words were reshaped), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated), nodesPainted (nodes the latest frame's paint walk entered, 0 when that frame reused the display list - the last rebuild's count is in `window.worst`; mountedNodes minus this is what viewport culling skipped - a long scroller should paint a near-constant number of nodes however long its content), backdropsPrepainted (backdropFilter panels the latest walk re-filtered ahead of a fading ancestor's opacity group so the glass keeps its blur through the fade; nonzero only while such a fade runs, one extra backdrop capture per panel per frame; 0 on a reuse frame like nodesPainted, so read the window's total, `window.backdropsPrepainted`), damagePx (the latest rebuild's resolved screen-damage area in logical px^2, the union of every changed node's old and new window extents: window area on a fully damaged frame - resize, first paint, or a change without a trustworthy extent - and a small number when only a small region changed; a small animation reporting window-sized damagePx means its damage is not being contained), paintOps (the display-list ops the latest rebuild's paint walk recorded, also in window.worst for the worst frame: draws; textLayers, the text layers the glyph pass rasterized for the rebuild (a text whose pixels are current composites its retained layer, which is a draw and no layer); clips, of which roundedClips; saveLayers; and blends and gradients, the paints that leave a tiled GPU's cheap path - the counts that name a paint cost on Android without a reload per hypothesis; ops inside a reused repaint-boundary recording are not counted). The window's `targets` list splits the pass figures per draw target that rendered in it (label as created, id 0 = node shaders): passesPerFrame, gpuPassIssueMsPerFrame, gpuPassExecMsPerFrame (absent without timer queries) and verticesPerFrame (vertices, or indices on an indexed draw, submitted per presented frame: a vertex-bound pass shows here before any timer does; fill still needs subtraction). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed at the instant of the query, including the one executing; the frame command blocks on vsync in it, so 1 while frames flow is normal - it is a backlog signal only when it climbs across queries while fps drops; a persistently high idle reading has been seen once on a Windows client and is unexplained, so do not conclude from this field alone), partialPresents (cumulative presents that redrew only a damage patch over a preserved back buffer - partial repaint; 0 where EGL buffer age is unavailable, and climbing in step with presented frames when a small animation is being contained), idleTicks (cumulative idle frame signals emitted while the GPU had nothing queued; idleTicks racing while rasterQueue sits nonzero would mean the idle-tick gate is broken), fenceTimeouts (cumulative present-fence waits that expired instead of signaling - each one is a frame where the GPU was over budget for 100ms+ and one-frame-in-flight pacing was lost; zero on a healthy machine, climbing means the GPU is the bottleneck right now), missedPresents (cumulative missed presents, the window figure's raw counter - diff your own samples with it; the window's count is the one to quote), gpuPasses/gpuPassIssueMs/gpuPassExecMs (cumulative shader/pipeline target renders on the raster thread, the wall time the raster thread spent issuing them, and the GPU-side time executing them, all in whole ms - diff two queries to get a rate; passes racing far ahead of frames means redundant target re-renders, the failure mode where fps and frameMs look healthy while the raster thread drowns; issue and exec are different clocks: a pass with a heavy fragment shader is cheap to issue and expensive to execute, so a busy GPU with a small issue figure is normal, and gpuPassExecMs is the number to compare against the refresh period. gpuPassExecMs comes from GL timer queries and lags the pass by a frame or two; it is absent, not 0, when the client's context has none or its driver failed the startup attribution self-test - some tiled GPUs book deferred pass execution to whichever query is open, so their split would mislead; measure by subtraction there, differencing frame counts against timeMs), gpuFrameExecMs (cumulative GPU-side time executing the window draw of each presented frame - the display list plus any window shader, excluding the pass flush and the present - from the same timer queries, same absence rule; gpuFrameExecMsPerFrame in the window is the number to hold against periodMs: near or above it, the GPU is the bottleneck and fenceTimeouts follow, while a healthy jsMs says nothing about it), gpuDrawSlices/gpuDrawSliceWaitMs (cumulative extra GPU submissions the submission budget issued by slicing heavy draws so no single submission runs past the driver's hang timeout, and the raster-thread wall time waiting on their fences, which gpuPassIssueMs includes: 0 on a healthy desktop, climbing on a weak GPU under a heavy draw; the inventory's draws carry slices and submissionMs per entry), rasterCmdMs (cumulative wall time in whole ms the raster thread spent executing non-frame commands - texture uploads, readbacks, offscreen rasterizations, shader compiles, param writes and the target re-renders they trigger; the work frameMs never sees, so rasterCmdMs growing much faster than frames are presented means the raster thread is drowning in side work even if every counter above looks calm). The residency gate (streaming worlds: the 2d tile layer's chunks, 3d regions) reads live: regionsWanted, regionsResident, regionsPending, regionsStalled (held back by a texel or weight limit, waiting on a view move), cumulative regionEnters/regionLeaves (the window carries its own pair over its span) and residentWeight (a tile layer's resident texels) - a streaming verdict is a flat regionsResident and residentWeight while the camera roams. `app` holds the counters the app registered with sol:dev registerStat (name to number; null where a reader failed), the core's resource pools among them: poolHits/poolMisses (cumulative takes that reused an id or created one; hits over the sum is the reuse rate) and poolHeld (ids waiting in pools now).", inputSchema: { window_ms: z .number() .int() .min(0) .max(10000) .describe("How far back the window summary looks, in ms (default 5000, max 10000)") .optional(), window_frames: z .number() .int() .min(1) .max(600) .describe( "The last n frames that changed the picture, in place of a time window: the window for a frozen clock (set_time_scale 0), where stepped frames fall out of a time window while you read it (max 600)", ) .optional(), client: CLIENT_ARG, }, }, { name: "set_stats_overlay", annotations: SETS_STATE, description: "Switch the on-screen stats overlay (fps, frame times, memory, drawn in a corner of the app window) on or off. With `client` it applies to that one client; without, to every connected client and to clients joining later. Use it when the human at a device should read the figures live, e.g. while reproducing a stutter on a phone; for your own measurements, get_stats reads the same numbers without changing the picture. Returns the state now in force and the number of clients told; list_clients reports each client's `stats`.", inputSchema: { active: z.boolean().describe("true draws the overlay, false hides it"), client: CLIENT_ARG, }, }, { name: "get_render_tree", annotations: READ_ONLY, description: "Snapshot of a running app client\'s render tree, the /tree control endpoint verbatim: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. The per-node record - props (JSX names, off-default values only), quad under a transform, exiting/exit on a removal cascade, slide mid-slide, detached, childCount - is documented once, in agents/debugging.md under \'The control API without MCP\'; read the /tree entry there for what each field means and how to read animations off it with a frozen clock. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` (+ props) to inspect the region around a match; a node whose children were cut off by `depth` carries `childCount`, descend into it with root=.", inputSchema: { root: z .number() .int() .describe("Only return the subtree under this node id (default: the whole tree)") .optional(), depth: z .number() .int() .describe("Levels of children to include below the root (default: unlimited; 0 = the root node only)") .optional(), query: z .string() .describe( "Search instead of snapshot: return `matches`, nodes whose kind equals or whose text or label " + "contains this (case-insensitive), each with a `path` of ancestor ids from the search root. Combine " + "with `root` to scope the search; `depth` is ignored.", ) .optional(), at: z .object({ x: z.number(), y: z.number() }) .describe( "Hit test instead of snapshot: return `hit`, the nodes a pointer at this window point (logical points) " + "reaches, root first, the node it lands on last; empty when nothing takes a pointer there. What a tap " + "at that point would reach, e.g. to see what covers a node that does not respond.", ) .optional(), props: z .boolean() .describe("Include each node's current off-default property values and, for transformed nodes, the painted quad") .optional(), client: CLIENT_ARG, }, }, { name: "get_snapshot", annotations: READ_ONLY, description: "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. Capture the smallest node that contains what you are checking (e.g. the leaf itself) - that is exactly the content at its own pixel size; the window root is mostly empty layout around it and orders of magnitude more pixels. Reserve root captures for when layout/positioning itself is the question. The node must be currently mounted and paint a non-zero box. Detached (`d-*`) nodes capture their painted box: their own `w`/`h` when set, else the box inherited from the nearest laid-out ancestor (the same box get_render_tree reports for them). A capture renders only that node's subtree, with no ancestor paint: pixels nothing in the subtree draws come back transparent, not the background an ancestor draws behind the node - capture the window root when the background matters. Pass x/y/width/height to crop and `scale` to magnify: captures may be downscaled before you see them, so verify small hand-authored geometry (sprites, path data, icons) with a tight crop at 4x-8x rather than squinting at a full capture. Crop coordinates are in captured-image pixels (the width x height a capture of that node reports - device pixels), not the logical units get_render_tree reports. Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle. The capture paints the current state afresh, after every pending microtask, so a glitch confined to one frame never shows in it: with the clock paused, pass `step: true` to advance one frame and capture inside it, the picture exactly as that frame's own code drew it. If you are MEASURING rather than looking - reading positions or sizes out of the image, comparing captures, deciding whether the app placed something correctly - call mute_user_input first: the human's own dragging and clicking is otherwise in your result, and a capture of their pose looks exactly like a capture of yours, so the numbers disagree with the code and send you debugging code that was never wrong. This tool always returns the PNG; for pixel assertions in a script, the dev server's control API answers /snapshot?node=&format=raw with the RGBA8 bytes instead (agents/debugging.md).", inputSchema: { nodeId: z .number() .int() .describe("Id of the node to capture, from get_render_tree; prefer the smallest relevant node over the root"), x: z .number() .int() .describe("Crop rect left edge in captured-image pixels (requires y, width, height)") .optional(), y: z.number().int().describe("Crop rect top edge in captured-image pixels").optional(), width: z.number().int().describe("Crop rect width in captured-image pixels").optional(), height: z.number().int().describe("Crop rect height in captured-image pixels").optional(), scale: z .number() .int() .min(1) .max(8) .describe( "Integer magnification, 1-8: each captured pixel becomes an NxN block (nearest-neighbour), so you see " + "the actual rendered pixels enlarged. Combine with a crop; the scaled output is capped at 8192 px per side", ) .optional(), step: z .boolean() .describe( "With the clock paused (set_time_scale 0): advance one frame and capture inside it, so the image is that " + "frame exactly as its own code drew it, before anything the frame left to a microtask or a promise lands " + "- the only way to see a glitch confined to one frame. Without it the capture paints the current state " + "afresh, after every pending microtask. Refused while the clock runs: pause first", ) .optional(), save_to: SAVE_TO_ARG, client: CLIENT_ARG, }, }, { name: "get_gpu_resources", annotations: READ_ONLY, description: "Inventory of a running app client's GPU resources: textures (id, size - width, height and the depth of a 3D or array texture - shape (2d, cube, 3d, 2d-array), format, byteLength - the texel storage at that format, mip chain, cube faces and volume slices included - whether a shader renders into it, and its declared sampler - filter/wrap/mipmap/anisotropy - the first thing to check when a map looks soft or aliased), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, the bound `buffers` ids in layout order, topology, drawCount plus firstVertex/instanceCount when off their 0/1 defaults, slices and submissionMs on an entry the submission budget cut (the submissions its last render was issued as, and the longest one in ms - the entry's own GPU cost on a weak device), depth, the declared buffer layouts (stepMode, arrayStride, attributes with offsets), bound sampler texture ids, current uniform values - the most recent writes, which the next frame or readback draws with - plus passes/issueMs/execMs, cumulative per-target render count, raster-thread issue time and GPU-side execution time in whole ms: when get_stats shows gpuPasses or gpuPassExecMs running hot, these attribute the cost to the specific target). Use it when the render tree is just a leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents. Pass `label` to keep only the resources created with exactly that debug label (the create's `label` option) - the stable way to find a target again after a reload, since ids change. In a draw target's entry list, uniforms wider than a vec4 (matrices) are elided to their length (\"[16]\") so a model's hundred entries stay readable; pass `draw` (an entry id from that list, with `label` to pin the target, since entry ids are per target) to get that one entry's params in full. `groups` sums the textures and buffers by exact label (textures, textureBytes, buffers, bufferBytes, bytes): a consumer that labels everything it creates (a tile layer's chunks under the layer's label, a model's parts) reads its footprint as one number before and after a residency change - what is allocated under the label, pooled idle targets included (get_stats app.poolHeld counts those).", inputSchema: { label: z.string().describe("Keep only resources whose create label equals this").optional(), draw: z.number().int().describe("Draw entry id whose params are reported in full (pair with label)").optional(), client: CLIENT_ARG, }, }, { name: "get_texture", annotations: READ_ONLY, description: "Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShaderTexture/createPipelineTexture in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame: a render target reads as its current output, with any pending params, geometry or sampled-input changes resolved first. Pass x/y/width/height to crop, e.g. one tile of an atlas, and `scale` to magnify small content like a single tile or glyph. This tool always returns the PNG; for pixel assertions in a script, the dev server's control API answers /texture?id=&format=raw with the RGBA8 bytes instead (agents/debugging.md).", inputSchema: { id: z.number().int().describe("Texture id, from get_gpu_resources"), x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(), y: z.number().int().describe("Crop rect top edge in texture pixels").optional(), width: z.number().int().describe("Crop rect width in texture pixels").optional(), height: z.number().int().describe("Crop rect height in texture pixels").optional(), scale: z .number() .int() .min(1) .max(8) .describe( "Integer magnification, 1-8: each texture pixel becomes an NxN block (nearest-neighbour). Combine with " + "a crop; the scaled output is capped at 8192 px per side", ) .optional(), save_to: SAVE_TO_ARG, client: CLIENT_ARG, }, }, { name: "get_buffer", annotations: READ_ONLY, description: "Read back part of a GPU vertex buffer from a running app client, decoded to numbers. Returns values plus byteOffset/byteLength actually read and bufferByteLength. Reads are capped at 64 KiB per call; page through larger buffers with offset. Use it to verify geometry after a writeBuffer, e.g. the dynamic sprite tail of a vertex buffer.", inputSchema: { id: z.number().int().describe("Buffer id, from get_gpu_resources"), offset: z.number().int().describe("Byte offset to start reading at (default 0)").optional(), length: z.number().int().describe("Number of values to read (default: the rest of the buffer, capped)").optional(), as: z.enum(["f32", "u16", "u8"]).describe("How to decode the bytes (default f32)").optional(), client: CLIENT_ARG, }, }, { name: "list_debug", annotations: READ_ONLY, description: "List the debug commands the running app registered via registerDebug from sol:dev. Returns the command names; call one with call_debug. Empty when the app registered none.", inputSchema: { client: CLIENT_ARG }, }, { name: "call_debug", annotations: DRIVES_APP, description: "Call a debug command the running app registered via registerDebug from sol:dev, by name (from list_debug). `args` is passed to the command's function as its single argument, as the value itself - the bridge serializes it, so do not JSON-encode it yourself: a string goes bare (`arcana`, not `\"arcana\"`, which arrives as a string with literal quote characters), a number as a number, structured input as an object; omit it for none. The command's return value comes back JSON-serialized (undefined as null). Commands run synchronously on the app's JS thread - use them to query app state (positions, counters, internal flags) or trigger app behavior (toggle a mode, open a door) without touching its real input handling.", inputSchema: { name: z.string().describe("Debug command name, from list_debug"), args: z.any().describe("Argument passed to the command, as the value itself, not JSON text: a bare string (arcana, not \"arcana\"), a number, a boolean, or an object/array (default: none)").optional(), client: CLIENT_ARG, }, }, { name: "get_dialog", annotations: READ_ONLY, description: "The file dialog state of the running app (flux:dialog): `pending`, the request waiting for an answer (its kind openFiles/saveFile/openDirectory and options: multiple, suggestedName, types, startIn) or null; `last`, the last request made with its `answer` (picked entries, cancelled, or failed); `queued`, how many answers wait for the next request; `available`, whether this client's platform can show a native dialog at all. Read it to verify what the app asked for (the filters, the suggested name), then answer with answer_dialog.", inputSchema: { client: CLIENT_ARG }, }, { name: "answer_dialog", annotations: DRIVES_APP, description: "Answer the app's file dialog in place of a user clicking it: `files` (paths in the client's own file system, for openFiles or saveFile), `directory` (a path, for openDirectory) or `cancel: true`. A request waiting for an answer takes it at once (`answered: true`); otherwise the answer is queued for the next request the app makes (`queued: true`), so queue it BEFORE triggering the flow that opens the dialog: on a visible client an unanswered request shows the native dialog, which nothing can dismiss remotely, and only the user can finish it. Under the test host no dialog is ever shown. The app gets flux:fs handles on the paths, exactly as from a real pick; a cancel resolves its call with an empty list or null.", inputSchema: { files: z.array(z.string()).describe("Paths to answer an openFiles (any number) or saveFile (the first) request with").optional(), directory: z.string().describe("Path to answer an openDirectory request with").optional(), cancel: z.boolean().describe("true to answer as a cancel").optional(), client: CLIENT_ARG, }, }, { name: "open_link", annotations: DRIVES_APP, description: "Deliver a link to the running app, the way a custom-scheme link routed by the OS arrives: the raw string reaches the app's onLink handlers (a router opens the screen it names; an app without a router handles it itself). Use it to open a screen directly instead of tapping through the tree - `/settings/theme` or `myapp://settings/theme`, whichever the app documents. Returns delivered: false when nothing in the app listens, in which case the link was dropped. Follow with get_location or get_snapshot to see where the app landed.", inputSchema: { link: z.string().describe("The link, raw: a path like /settings/theme, or a full scheme link like myapp://settings/theme"), client: CLIENT_ARG, }, }, { name: "get_location", annotations: READ_ONLY, description: "Read where the app is: the location it reports through reportLocation from sol:dev (a router's current path). null when the app reports none. The cheap way to confirm a navigation or an open_link landed before taking a snapshot. A reload starts the app at this location again.", inputSchema: { client: CLIENT_ARG }, }, { name: "reload", description: "Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. The server also reloads on save (every file the running bundle was built from, and the assets/ tree, so writing assets counts as editing); pause_watch before an edit burst or a data-generating script so half-finished saves are not pushed to the user's screens, then reload, then resume_watch. Follow with get_logs to see runtime output from the reloaded app.", inputSchema: {}, }, { name: "load", description: "Switch the app entry: bundle the given .tsx/.jsx source file and push it to every connected client, replacing whatever is running; later reload calls rebuild this entry. A server started for a project (list_clients: mode 'project') only loads files inside that project; one started for a single file (mode 'file') loads any file. Returns the entry now served and the number of clients loaded, or a build error if the source failed to compile.", inputSchema: { entry: z.string().describe("App entry source file to load (relative paths resolve against the bridge's working directory, normally the project root)"), }, }, { name: "mute_user_input", annotations: SETS_STATE, description: "Mute the user's own input (pointer, keyboard, text, wheel, gamepads, back) on every connected client until unmute_user_input, so a measurement or an interaction test is not disturbed by a stray click or keypress. send_input still goes through; window events (resize, close) cannot be muted. Call it the moment you start measuring or testing, before the first send_input or get_stats, and keep it short: the human sees an unresponsive client meanwhile. The mute survives reload; it lifts on unmute_user_input, when the dev server goes away, or when this bridge exits. ALWAYS unmute when you are done, and whenever you need the human to press something themselves.", inputSchema: {}, }, { name: "unmute_user_input", annotations: SETS_STATE, description: "Lift the mute set by mute_user_input: the user's input reaches every client again. Call it as soon as your measurement or test is done, whenever the human needs to interact, and always before you stop working.", inputSchema: {}, }, { name: "pause_watch", annotations: SETS_STATE, description: "Pause the dev server's reload-on-save until resume_watch, so your half-finished saves are not pushed to the user's screens while you edit, or while you GENERATE files the app reads - the assets/ tree is watched too, so a data-prep script writing into it rebuilds and reloads the app on every write. Call it before an edit burst or a generating script; when the edits are done, reload, then resume_watch. Changes saved while paused are not replayed on resume: reload is what pushes them. The pause lifts on resume_watch, when the dev server goes away, or when this bridge exits. ALWAYS resume when you are done: while paused, the human's own saves reach nothing. Returns `{ ok, watchPaused }`.", inputSchema: {}, }, { name: "resume_watch", annotations: SETS_STATE, description: "Lift the pause set by pause_watch: saves push again, the human's included. Call it after your reload, and always before you stop working. Returns `{ ok, watchPaused }`.", inputSchema: {}, }, { name: "set_time_scale", annotations: SETS_STATE, description: "Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, and the picture stops (performance.now() and Date.now() keep running: they are real time, not the frame timeline, so only animations driven off the onFrame tick pause) - so get_snapshot can capture an exact frame of any animation instead of racing it (tool round trips are usually slower than the animation). Combine with a registerDebug command that sets up the state to photograph: set state, pause, snapshot. Other values scale time for dt-driven apps (0.5 = half speed, 2 = double); apps that advance a fixed amount per onFrame call only respond to 0 and 1. The scale is client runtime state: it survives across your snapshots but resets to 1 on reload and on client restart. ALWAYS set it back to 1 when you are done - a paused client looks wedged to the human watching the screen.", inputSchema: { scale: z .number() .min(0) .describe("Time scale: 0 = pause, 1 = normal, 0.5 = half speed, 2 = double speed"), client: CLIENT_ARG, }, }, { name: "step_frames", annotations: DRIVES_APP, description: "While paused (set_time_scale 0), advance a running app client by exactly n frames: each frame moves app time forward one refresh period (~16.7 ms at 60 Hz), runs onFrame/requestAnimationFrame and any timers that come due, and presents the result. Deterministic single-stepping for animations and game logic: pause, snapshot, step, snapshot again to see exactly what changed in n frames. A snapshot after a step paints the settled state afresh; to see a stepped frame exactly as its own code drew it, call get_snapshot with step: true instead, which advances that one frame and captures inside it. With the clock running this is a no-op (frames already flow). Steps are applied at the client's frame rate, so n frames take about n refresh periods of wall time before a following snapshot shows the result.", inputSchema: { n: z.number().int().min(1).max(1000).describe("Number of frames to advance (1-1000)"), client: CLIENT_ARG, }, }, { name: "settle", annotations: READ_ONLY, description: "Wait until a running app client is at rest, in place of a sleep: nothing the app started is still in flight (a fetch, a file or body read, a query, an isolate call), no timer is due, and no frame is demanded (no running transition, no pending tree write). Call it after send_input, call_debug, open_link or reload and before reading the tree or taking a snapshot, so the read sees the finished state. Returns `settled` (true when the app came to rest within max_ms) and `waitedMs`; when false, what is left: `demand` (why frames are still wanted: \"onFrame\", \"a transition on view labelled ...\", \"a playing video\", \"requestAnimationFrame\", ...), `inFlight` (a count per kind of work, e.g. {fetch: 1}), `timerDue` and `idleDue`. An app that animates on its own (a game loop, a looping animation, a video) never settles: that is the answer, not an error - freeze it with set_time_scale 0 and step_frames instead, or, when something loads behind the animation (a spinner over a model load), pass frames: false to wait for the app's work alone: nothing in flight, and a frame after the last of it in which nothing more started. What stands is not waited for: servers, open sockets, timers not yet due. With max_ms 0 it reads the state without waiting.", inputSchema: { max_ms: z .number() .int() .min(0) .max(30000) .describe("How long to wait for rest, in milliseconds of wall time (default 5000, max 30000)") .optional(), frames: z .boolean() .describe("false: wait for the app's work alone (loads, fetches, decodes), not for its frames to stop; for an app that animates while something loads (default true)") .optional(), client: CLIENT_ARG, }, }, { name: "send_input", annotations: DRIVES_APP, description: "Send synthetic input to a running app client through the real input pipeline (hit testing, focus, event bubbling) - the same path physical input takes, unlike call_debug which sets state directly, so use this to verify interactions actually work. Events run in order; each may wait delayMs (0-5000 ms) before firing, and the call returns after the last event has entered the pipeline, so a following get_snapshot sees the result. Event kinds: {type:'pointer', action:'down'|'up'|'cancel'|'move'|'tap'|'drag', x, y} for clicks and drags - coordinates in logical points, the same space get_render_tree reports; 'cancel' ends a pressed pointer the way the system does when it takes a touch away (a back swipe, palm rejection): the app sees onPointerCancel, never an up, so a press fires nothing and a drag ends without a fling; 'tap' is down, then up a frame (and holdMs, if given) later, never in the same frame; 'drag' is a down at x,y, one move per frame along the line to `to` over durationMs (default 300), and an up there; a mouse tap or drag first moves the pointer to x,y, a frame ahead of the down, so hover is what a real mouse leaves; button 0 = left (default), 1 = middle, 2 = right; pointerType 'mouse' (default) keeps hovering at its last position afterwards like a real cursor, use 'touch' for gestures that should end hover-free. {type:'key', action:'down'|'up'|'tap', key} with W3C key names exactly as the runtime reports them ('w', 'ArrowLeft', 'Enter', ' '); a 'tap' with holdMs holds the key down that long, e.g. holdMs 500 = walk forward half a second in one call; modifier booleans shift/ctrl/alt/meta. {type:'text', text} enters text through the TextInput path - focus the target first with a pointer tap on it (the tap also activates the text session). {type:'wheel', x, y, deltaX, deltaY} scrolls; deltas reach the app unscaled, in a physical wheel's units (one mouse notch is 100), positive deltaY scrolls content down. {type:'gamepad', action:'connect'|'set'|'disconnect', slot, buttons, axes} drives a synthetic gamepad through the same pad pipeline physical pads use (gamepads(), the input map's gamepad devices, gamepad.next() joining, focus navigation): 'connect' seats a pad (lowest free slot unless slot is given; read the slot back from the app or count from 0), 'set' holds a LEVEL state - the named buttons down and the axes at those values - until the next set, so a press is set {buttons:['south']} then set {} (or one set with holdMs), a stick pushed up is set {axes:{leftY:-1}}; 'disconnect' frees the slot. The mute does not affect synthetic pads. {type:'back'} is the user's back intent, the one event the Android back button, a pad's back button and the desktop dev chord become: it runs the app's onBack stack (a focused TextInput cancels, an open Modal, Select or ContextMenu closes, the router pops) and never arrives as a key. Recipes: click a button = [{type:'pointer',action:'tap',x:400,y:300}]. Drag = [{type:'pointer',action:'drag',x:100,y:300,to:{x:400,y:300},durationMs:300}]. Tap a pad button = [{type:'gamepad',action:'connect'},{type:'gamepad',action:'set',slot:0,buttons:['south'],holdMs:100}]. Deterministic interaction test = set_time_scale 0, send_input, step_frames, get_snapshot. A down/up over empty space hits nothing, exactly like real input - check coordinates against get_render_tree when a click seems to do nothing.", inputSchema: { events: z.array(EVENT_ARG).min(1).max(200).describe("Event sequence, executed in order"), client: CLIENT_ARG, }, }, ] function clientParam(args: any): string { return typeof args?.client === "number" ? `?client=${args.client}` : "" } // The crop rect is all four of x/y/width/height or none. The server checks // the same rule; catching it here hands the agent the rule without a round // trip. Returns the error message, or undefined when the rect is well-formed. function partialCrop(tool: string, args: any): string | undefined { let given = ["x", "y", "width", "height"].filter((k) => typeof args?.[k] === "number").length return given === 0 || given === 4 ? undefined : `${tool}: a crop needs all four of x, y, width, height` } async function callTool(name: string, args: any): Promise { switch (name) { case "list_clients": return control("/clients") case "get_logs": { let params = new URLSearchParams() if (typeof args?.since === "number") params.set("since", String(args.since)) if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms)) if (typeof args?.level === "string") params.set("level", args.level) if (typeof args?.contains === "string") params.set("contains", args.contains) if (typeof args?.client === "number") params.set("client", String(args.client)) let qs = params.toString() return control(qs ? `/logs?${qs}` : "/logs") } case "get_stats": { let params = new URLSearchParams() if (typeof args?.window_ms === "number") params.set("window", String(args.window_ms)) if (typeof args?.window_frames === "number") params.set("frames", String(args.window_frames)) if (typeof args?.client === "number") params.set("client", String(args.client)) let qs = params.toString() return control(qs ? `/stats?${qs}` : "/stats") } case "set_stats_overlay": { if (typeof args?.active !== "boolean") return { ok: false, message: "set_stats_overlay requires active: true|false" } let params = new URLSearchParams({ active: String(args.active) }) if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/stats?${params.toString()}`, "POST") } case "get_render_tree": { let params = new URLSearchParams() if (typeof args?.root === "number") params.set("root", String(args.root)) if (typeof args?.depth === "number") params.set("depth", String(args.depth)) if (typeof args?.query === "string") params.set("query", args.query) if (args?.at && typeof args.at === "object") { let at = args.at as { x: number; y: number } params.set("at", `${at.x},${at.y}`) } if (args?.props === true) params.set("props", "true") if (typeof args?.client === "number") params.set("client", String(args.client)) let qs = params.toString() return control(qs ? `/tree?${qs}` : "/tree") } case "reload": return control("/reload", "POST") case "load": { if (typeof args?.entry !== "string" || !args.entry) return { ok: false, message: "load requires an entry path" } // Resolved here, against the bridge's cwd: the server would resolve // against the project root (or the served file's directory), which // the agent may not be sitting in. return control("/load", "POST", { entry: resolve(args.entry) }) } case "mute_user_input": case "unmute_user_input": { let active = name === "mute_user_input" let result = await control(`/mute?active=${active}`, "POST") if (result.ok) muted = active return result } case "pause_watch": case "resume_watch": { let paused = name === "pause_watch" let result = await control(`/watch?active=${!paused}`, "POST") if (!result.ok) return result watchPaused = paused // The control API answers in its uniform `active` (whether it watches); // name the state as list_clients does, so an `active: false` cannot be // read as "the pause is not active". return { ...result, body: { ok: true, watchPaused } } } case "get_snapshot": { if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" } let crop = partialCrop("get_snapshot", args) if (crop) return { ok: false, message: crop } let params = new URLSearchParams({ node: String(args.nodeId) }) for (let key of ["x", "y", "width", "height", "scale"]) { if (typeof args?.[key] === "number") params.set(key, String(args[key])) } if (args?.step === true) params.set("step", "1") if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/snapshot?${params.toString()}`) } case "set_time_scale": { if (typeof args?.scale !== "number" || !(args.scale >= 0)) { return { ok: false, message: "set_time_scale requires scale >= 0" } } let params = new URLSearchParams({ scale: String(args.scale) }) if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/clock?${params.toString()}`, "POST") } case "step_frames": { if (typeof args?.n !== "number" || !(args.n >= 1)) return { ok: false, message: "step_frames requires n >= 1" } let params = new URLSearchParams({ step: String(args.n) }) if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/clock?${params.toString()}`, "POST") } case "settle": { let params = new URLSearchParams() if (typeof args?.max_ms === "number") params.set("max", String(args.max_ms)) if (args?.frames === false) params.set("frames", "0") if (typeof args?.client === "number") params.set("client", String(args.client)) let qs = params.toString() return control(`/settle${qs ? `?${qs}` : ""}`) } case "send_input": { if (!Array.isArray(args?.events) || args.events.length === 0) return { ok: false, message: "send_input requires a non-empty events array" } return control(`/input${clientParam(args)}`, "POST", { events: args.events }) } case "get_gpu_resources": { let params = new URLSearchParams() if (typeof args?.label === "string") params.set("label", args.label) if (typeof args?.draw === "number") params.set("draw", String(args.draw)) if (typeof args?.client === "number") params.set("client", String(args.client)) let qs = params.toString() return control(`/gpu${qs ? `?${qs}` : ""}`) } case "list_debug": return control(`/debug${clientParam(args)}`) case "open_link": { if (typeof args?.link !== "string" || !args.link) return { ok: false, message: "open_link requires a link" } let params = new URLSearchParams({ link: args.link }) if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/link?${params.toString()}`, "POST") } case "get_location": return control(`/link${clientParam(args)}`) case "get_dialog": return control(`/dialog${clientParam(args)}`) case "answer_dialog": { let answer: Record = {} if (Array.isArray(args?.files)) answer.files = args.files else if (typeof args?.directory === "string") answer.directory = args.directory else if (args?.cancel === true) answer.cancel = true else return { ok: false, message: "answer_dialog requires files, directory or cancel: true" } return control(`/dialog${clientParam(args)}`, "POST", answer) } case "call_debug": { if (typeof args?.name !== "string") return { ok: false, message: "call_debug requires a command name" } let params = new URLSearchParams({ name: args.name }) if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/debug?${params.toString()}`, "POST", args?.args) } case "get_texture": { if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" } let crop = partialCrop("get_texture", args) if (crop) return { ok: false, message: crop } let params = new URLSearchParams({ id: String(args.id) }) for (let key of ["x", "y", "width", "height", "scale"]) { if (typeof args?.[key] === "number") params.set(key, String(args[key])) } if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/texture?${params.toString()}`) } case "get_buffer": { if (typeof args?.id !== "number") return { ok: false, message: "get_buffer requires a numeric id" } let params = new URLSearchParams({ id: String(args.id) }) if (typeof args?.offset === "number") params.set("offset", String(args.offset)) if (typeof args?.length === "number") params.set("length", String(args.length)) if (typeof args?.as === "string") params.set("as", args.as) if (typeof args?.client === "number") params.set("client", String(args.client)) return control(`/buffer?${params.toString()}`) } default: return { ok: false, message: `Unknown tool: ${name}` } } } async function toContent(name: string, result: ControlResult, args?: any): Promise { if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true } let content: CallToolResult["content"] = [] if (result.note) content.push({ type: "text", text: result.note }) // The bridge never asks for format=raw, so the reply carries the PNG; a // reply without one is left as JSON rather than passed off as an image. let image = name === "get_snapshot" || name === "get_texture" ? (result.body as ImageResponse) : null if (image?.pngBase64) { let { pngBase64, width, height } = image let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents" let text = `${label}: ${width}x${height} px` // save_to is handled here in the bridge, not by the dev server: this // process runs on the caller's machine, so the path lands where the // agent expects it. The image content block alone is a dead end for // that - the model sees the pixels but never the bytes. if (typeof args?.save_to === "string") { let path = resolve(args.save_to) try { await Bun.write(path, Buffer.from(pngBase64, "base64")) text += `, saved to ${path}` } catch (e) { return { content: [{ type: "text", text: `Captured, but saving to ${path} failed: ${e}` }], isError: true } } } content.push({ type: "image", data: pngBase64, mimeType: "image/png" }, { type: "text", text }) return { content } } content.push({ type: "text", text: JSON.stringify(result.body, null, 2) }) return { content } } // Whether this bridge muted the user's input and has not unmuted, and // whether it paused reload-on-save and has not resumed. Either outliving the // bridge would leave the user locked out of their own client, or saving into // nothing, so the bridge lifts both when the agent host closes the pipe or // kills it. let muted = false let watchPaused = false async function restoreOnExit() { if (muted) { muted = false await control("/mute?active=false", "POST") } if (watchPaused) { watchPaused = false await control("/watch?active=true", "POST") } } // Requests the host sent that have no answer yet, by id. The host closing // stdin is its "done", but a request already read still owes its answer and // stdout stays open, so exit waits for those first. Counted at the transport // (entering onmessage, leaving with the response write) rather than in the // tool handler: a request read from the pipe can sit undispatched when the // pipe closes right behind it. Capped, so a wedged dev server cannot keep a // closed bridge alive. let pending = new Set() let drained: Array<() => void> = [] const EXIT_GRACE_MS = 5000 let exiting = false async function shutdown() { if (exiting) return exiting = true if (pending.size > 0) { await Promise.race([ new Promise((resolve) => drained.push(resolve)), new Promise((resolve) => setTimeout(resolve, EXIT_GRACE_MS)), ]) } await restoreOnExit() process.exit(0) } export async function main() { let server = new McpServer({ name: "solidrt", version: CLI_VERSION }) for (let tool of TOOLS) { server.registerTool( tool.name, { description: tool.description, inputSchema: tool.inputSchema, annotations: { openWorldHint: false, ...tool.annotations }, }, async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args), ) } let transport = new StdioServerTransport() let send = transport.send.bind(transport) transport.send = async (message: JSONRPCMessage) => { await send(message) if ("id" in message && message.id !== undefined && !("method" in message)) { pending.delete(message.id) if (pending.size === 0) for (let wake of drained.splice(0)) wake() } } // connect() installs the dispatching onmessage; the count wraps it. await server.connect(transport) let dispatch = transport.onmessage transport.onmessage = (message: JSONRPCMessage) => { if ("id" in message && "method" in message) pending.add(message.id) dispatch?.(message) } process.stdin.on("end", () => void shutdown()) for (let signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { process.on(signal, () => void shutdown()) } // The stdin read keeps the process alive; it exits when the agent host // closes the pipe (after answering the requests still pending and lifting // any mute or watch pause it set, see above). }