@solidrt/cli 0.0.51 → 0.0.53
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/AGENTS.md +68 -38
- package/README.md +86 -15
- package/agents/assets.md +19 -5
- package/agents/debugging.md +173 -17
- package/dist/console.srtapp +73916 -51
- package/dist/demos/3d/assets/icon.svg +23 -0
- package/dist/demos/3d/package.json +9 -0
- package/dist/demos/3d/the-third-dimension/the-third-dimension.srt.js +7528 -0
- package/dist/demos/components/assets/icon.png +0 -0
- package/dist/demos/components/assets/icon.svg +23 -0
- package/dist/demos/components/gallery/gallery.srt.js +14087 -0
- package/dist/demos/components/package.json +9 -0
- package/dist/server.js +3936 -0
- package/package.json +10 -11
- package/src/android/docs.md +21 -0
- package/src/android/main.ts +286 -0
- package/src/{bundler.ts → bundle/bundler.ts} +70 -58
- package/src/bundle/docs.md +12 -0
- package/src/{commands/bundle.ts → bundle/main.ts} +67 -25
- package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +3 -3
- package/src/check/docs.md +10 -0
- package/src/check/main.ts +85 -0
- package/src/{commands/check.ts → check/typecheck.ts} +11 -41
- package/src/client/docs.md +9 -0
- package/src/client/main.ts +33 -0
- package/src/console/docs.md +14 -0
- package/src/console/main.ts +21 -0
- package/src/demo/docs.md +33 -0
- package/src/demo/main.ts +69 -0
- package/src/init/docs.md +11 -0
- package/src/{commands/init.ts → init/main.ts} +21 -15
- package/src/init/scaffold/AGENTS.md +100 -0
- package/src/init/scaffold/package.json +23 -0
- package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
- package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
- package/src/lib/args.ts +194 -0
- package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
- package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
- package/src/{fonts.ts → lib/fonts.ts} +12 -26
- package/src/lib/mode.ts +77 -0
- package/src/{project.ts → lib/project.ts} +109 -61
- package/src/lib/registry.ts +120 -0
- package/src/lib/server-bundle.ts +24 -0
- package/src/lib/usage.ts +117 -0
- package/src/lib/util.ts +36 -0
- package/src/main.ts +109 -31
- package/src/mcp/docs.md +22 -0
- package/src/mcp/main.ts +719 -0
- package/src/pack/docs.md +18 -0
- package/src/{pack-folder.ts → pack/layout.ts} +13 -22
- package/src/{commands/pack.ts → pack/main.ts} +28 -17
- package/src/{packer.ts → pack/trailer.ts} +32 -42
- package/src/render/docs.md +19 -0
- package/src/{commands/render.ts → render/main.ts} +17 -15
- package/src/server/args.ts +126 -0
- package/src/server/binaries.ts +47 -0
- package/src/server/config.ts +54 -0
- package/{server → src/server}/control.ts +239 -81
- package/src/server/docs.md +51 -0
- package/src/server/line-editor.ts +200 -0
- package/src/server/main.ts +476 -0
- package/src/server/mode.ts +92 -0
- package/src/server/rebuild.ts +90 -0
- package/src/server/registry.ts +138 -0
- package/src/server/repl.ts +223 -0
- package/src/server/state.ts +54 -0
- package/{server → src/server}/tsconfig.json +1 -1
- package/{server → src/server}/tunnel.ts +6 -6
- package/src/server/watcher.ts +121 -0
- package/src/tool/main.ts +70 -0
- package/src/types/bundle.d.ts +24 -0
- package/src/types/control.d.ts +90 -0
- package/src/types/registry.d.ts +16 -0
- package/scaffold/AGENTS.md +0 -185
- package/scaffold/package.json +0 -22
- package/scaffold/templates/components/icon.tsx +0 -48
- package/scaffold/templates/default/icon.tsx +0 -48
- package/server/main.ts +0 -308
- package/server/rebuild.ts +0 -76
- package/server/state.ts +0 -94
- package/src/args.ts +0 -210
- package/src/bundle-cli.ts +0 -13
- package/src/commands/client.ts +0 -34
- package/src/commands/mcp.ts +0 -617
- package/src/commands/server.ts +0 -73
- package/src/dev-android.ts +0 -176
- package/src/dev-client.ts +0 -29
- package/src/dev-server.ts +0 -302
- package/src/repl.ts +0 -249
- package/src/util.ts +0 -122
- package/src/watcher.ts +0 -73
- /package/src/{prompt.ts → init/prompt.ts} +0 -0
- /package/{scaffold → src/init/scaffold}/gitignore +0 -0
- /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
- /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
- /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
- /package/{server → src/server}/cache.ts +0 -0
- /package/{server → src/server}/proxy.ts +0 -0
- /package/{server → src/server}/qr.ts +0 -0
- /package/{server → src/server}/remap.ts +0 -0
package/src/mcp/main.ts
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// The MCP bridge: a stdio Model Context Protocol server exposing the dev
|
|
2
|
+
// server's control API (/__control__/) as tools for coding agents. Stateless
|
|
3
|
+
// glue: every tool call is one HTTP request to the running dev server, so the
|
|
4
|
+
// bridge works no matter which process (or how many agents) spawned it.
|
|
5
|
+
//
|
|
6
|
+
// stdout is the JSON-RPC channel; nothing here may print to it.
|
|
7
|
+
|
|
8
|
+
import { z } from "zod"
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
11
|
+
import type { CallToolResult, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
+
import { resolve } from "node:path"
|
|
13
|
+
import { port as FIXED_PORT } from "../lib/args"
|
|
14
|
+
import { CLI_VERSION } from "../lib/project"
|
|
15
|
+
import { resolveFromCwd, sameKey } from "../lib/registry"
|
|
16
|
+
import type { ImageResponse } from "../types/control"
|
|
17
|
+
|
|
18
|
+
// An explicit --port pins the port for the bridge's lifetime. Otherwise the
|
|
19
|
+
// server is resolved from the registry by the bridge's cwd (once, then again
|
|
20
|
+
// whenever the server it found stops serving that key - see control()), so
|
|
21
|
+
// one bridge (started when the workspace opens, kept alive across server
|
|
22
|
+
// restarts) follows whichever server is currently serving this project - and
|
|
23
|
+
// the scaffold's mcp.json never carries a port.
|
|
24
|
+
|
|
25
|
+
// A pinned port carries no key: the user chose it, so nothing is checked.
|
|
26
|
+
type PortResult = { ok: true; port: number; key: string | null } | { ok: false; message: string }
|
|
27
|
+
|
|
28
|
+
async function resolvePort(): Promise<PortResult> {
|
|
29
|
+
if (FIXED_PORT !== undefined) return { ok: true, port: FIXED_PORT, key: null }
|
|
30
|
+
let resolved = await resolveFromCwd(process.cwd())
|
|
31
|
+
if (!resolved.ok) return resolved
|
|
32
|
+
// The record is a hint; the server is authoritative. A stale record (a pid
|
|
33
|
+
// reused by an unrelated process, a port taken over by another server)
|
|
34
|
+
// shows on the first call: every control response names the key it
|
|
35
|
+
// serves, and control() checks it.
|
|
36
|
+
return { ok: true, port: resolved.record.port, key: resolved.record.key }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `note` rides along with a good answer when the bridge has something the
|
|
40
|
+
// body does not say (see control()); errors fold it into the message.
|
|
41
|
+
type ControlResult = { ok: true; body: any; note?: string } | { ok: false; message: string }
|
|
42
|
+
|
|
43
|
+
// The resolved server, kept across calls: a server's port never changes while
|
|
44
|
+
// it runs, so the registry is read once and again only when the port stops
|
|
45
|
+
// answering or answers for another key (a server that died can be replaced
|
|
46
|
+
// by one serving something else on the port it remembered). Each response's
|
|
47
|
+
// x-solidrt-project header is that check, so a takeover shows on the next
|
|
48
|
+
// call at no extra cost.
|
|
49
|
+
let cached: { port: number; key: string | null } | null = null
|
|
50
|
+
|
|
51
|
+
// The server generation seen on the previous response. A restart invalidates
|
|
52
|
+
// every id and cursor the agent holds; comparing generations on each call
|
|
53
|
+
// makes that visible at once instead of as a puzzling "client gone".
|
|
54
|
+
let lastGeneration: string | null = null
|
|
55
|
+
const RESTART_NOTE =
|
|
56
|
+
"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)."
|
|
57
|
+
|
|
58
|
+
async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
|
|
59
|
+
for (let attempt = 0; ; attempt++) {
|
|
60
|
+
if (!cached) {
|
|
61
|
+
let resolved = await resolvePort()
|
|
62
|
+
if (!resolved.ok) return resolved
|
|
63
|
+
cached = { port: resolved.port, key: resolved.key }
|
|
64
|
+
}
|
|
65
|
+
let { port, key } = cached
|
|
66
|
+
let resp
|
|
67
|
+
try {
|
|
68
|
+
let init: RequestInit = { method }
|
|
69
|
+
if (payload !== undefined) {
|
|
70
|
+
init.headers = { "content-type": "application/json" }
|
|
71
|
+
init.body = JSON.stringify(payload)
|
|
72
|
+
}
|
|
73
|
+
resp = await fetch(`http://127.0.0.1:${port}/__control__${path}`, init)
|
|
74
|
+
} catch {
|
|
75
|
+
cached = null
|
|
76
|
+
if (attempt === 0) continue
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
message: `No dev server answers on port ${port}${key ? ` for ${key}` : ""}. Start one with srt run.`,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let served = resp.headers.get("x-solidrt-project")
|
|
83
|
+
if (key !== null && (served === null || !sameKey(served, key))) {
|
|
84
|
+
cached = null
|
|
85
|
+
if (attempt === 0) continue
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
message: `The server on port ${port} is not serving ${key}${served ? ` (it serves ${served})` : ""}. Start one with srt run, or pass --port <N> to srt mcp.`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let note: string | undefined
|
|
92
|
+
let generation = resp.headers.get("x-solidrt-generation")
|
|
93
|
+
if (generation !== null) {
|
|
94
|
+
if (lastGeneration !== null && generation !== lastGeneration) note = RESTART_NOTE
|
|
95
|
+
lastGeneration = generation
|
|
96
|
+
}
|
|
97
|
+
let body: any = null
|
|
98
|
+
try {
|
|
99
|
+
body = await resp.json()
|
|
100
|
+
} catch {}
|
|
101
|
+
if (!resp.ok) {
|
|
102
|
+
let message = String(body?.error ?? `Dev server responded with HTTP ${resp.status}`)
|
|
103
|
+
return { ok: false, message: note ? `${message} ${note}` : message }
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, body, note }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let CLIENT_ARG = z
|
|
110
|
+
.number()
|
|
111
|
+
.int()
|
|
112
|
+
.describe("Client id from list_clients (default: the only connected client; required when several are connected)")
|
|
113
|
+
.optional()
|
|
114
|
+
|
|
115
|
+
let SAVE_TO_ARG = z
|
|
116
|
+
.string()
|
|
117
|
+
.describe(
|
|
118
|
+
"Also write the PNG to this file path (relative paths resolve against the project root; parent directories are created)",
|
|
119
|
+
)
|
|
120
|
+
.optional()
|
|
121
|
+
|
|
122
|
+
// One synthetic input event, per type. The runtime rejects the whole
|
|
123
|
+
// sequence on one bad event, so the schema states each type's required
|
|
124
|
+
// fields up front: a pointer needs its action and position, a key its
|
|
125
|
+
// action and name, a wheel its position and both deltas, text its text.
|
|
126
|
+
// holdMs is a tap's down-to-up time and is refused on any other action.
|
|
127
|
+
let DELAY_ARG = z.number().int().min(0).max(5000).optional().describe("Wait before this event, ms")
|
|
128
|
+
let HOLD_ARG = z.number().int().min(0).max(5000).optional().describe("Tap only: time between down and up, ms")
|
|
129
|
+
let MODIFIER_ARGS = {
|
|
130
|
+
shift: z.boolean().optional(),
|
|
131
|
+
ctrl: z.boolean().optional(),
|
|
132
|
+
alt: z.boolean().optional(),
|
|
133
|
+
meta: z.boolean().optional(),
|
|
134
|
+
}
|
|
135
|
+
let EVENT_ARG = z.discriminatedUnion("type", [
|
|
136
|
+
z.object({
|
|
137
|
+
type: z.literal("pointer"),
|
|
138
|
+
action: z.enum(["down", "up", "move", "tap"]),
|
|
139
|
+
x: z.number().describe("Logical points, the space get_render_tree reports"),
|
|
140
|
+
y: z.number(),
|
|
141
|
+
button: z.number().int().min(0).max(4).optional().describe("0 = left (default), 1 = middle, 2 = right"),
|
|
142
|
+
pointerType: z.enum(["mouse", "touch"]).optional().describe("mouse (default) keeps hovering afterwards; touch ends hover-free"),
|
|
143
|
+
holdMs: HOLD_ARG,
|
|
144
|
+
delayMs: DELAY_ARG,
|
|
145
|
+
...MODIFIER_ARGS,
|
|
146
|
+
}),
|
|
147
|
+
z.object({
|
|
148
|
+
type: z.literal("key"),
|
|
149
|
+
action: z.enum(["down", "up", "tap"]),
|
|
150
|
+
key: z.string().min(1).describe("W3C key name as the runtime reports it ('w', 'ArrowLeft', 'Enter', ' ')"),
|
|
151
|
+
holdMs: HOLD_ARG,
|
|
152
|
+
delayMs: DELAY_ARG,
|
|
153
|
+
...MODIFIER_ARGS,
|
|
154
|
+
}),
|
|
155
|
+
z.object({
|
|
156
|
+
type: z.literal("wheel"),
|
|
157
|
+
x: z.number().describe("Logical points"),
|
|
158
|
+
y: z.number(),
|
|
159
|
+
deltaX: z.number(),
|
|
160
|
+
deltaY: z.number().describe("Positive scrolls content down"),
|
|
161
|
+
delayMs: DELAY_ARG,
|
|
162
|
+
...MODIFIER_ARGS,
|
|
163
|
+
}),
|
|
164
|
+
z.object({
|
|
165
|
+
type: z.literal("text"),
|
|
166
|
+
text: z.string().min(1).describe("Entered through the TextInput path; focus the target first"),
|
|
167
|
+
delayMs: DELAY_ARG,
|
|
168
|
+
}),
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
// MCP-standard tool annotations, so agent harnesses that honor them can
|
|
172
|
+
// auto-approve the harmless majority. The rule: readOnlyHint on tools that
|
|
173
|
+
// only inspect; destructiveHint: false on tools that drive the running app
|
|
174
|
+
// but leave the code it runs untouched (input, clock, debug commands, the
|
|
175
|
+
// holds, the overlay); idempotentHint: true where a call sets a state rather
|
|
176
|
+
// than performing an action. reload and load replace the running app and
|
|
177
|
+
// its state, so they keep the defaults (destructive, not idempotent). Every
|
|
178
|
+
// tool gets openWorldHint: false - the bridge only ever talks to the local
|
|
179
|
+
// dev server.
|
|
180
|
+
type Annotations = { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean }
|
|
181
|
+
let READ_ONLY: Annotations = { readOnlyHint: true }
|
|
182
|
+
let DRIVES_APP: Annotations = { destructiveHint: false }
|
|
183
|
+
let SETS_STATE: Annotations = { destructiveHint: false, idempotentHint: true }
|
|
184
|
+
|
|
185
|
+
let TOOLS: {
|
|
186
|
+
name: string
|
|
187
|
+
description: string
|
|
188
|
+
inputSchema: Record<string, z.ZodTypeAny>
|
|
189
|
+
annotations?: Annotations
|
|
190
|
+
}[] = [
|
|
191
|
+
{
|
|
192
|
+
name: "list_clients",
|
|
193
|
+
annotations: READ_ONLY,
|
|
194
|
+
description:
|
|
195
|
+
"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), and what the client knows about itself: `clientDir` (its storage tree on its own machine, `<data-root>/client<N>` 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, ...) 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.",
|
|
196
|
+
inputSchema: {},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: "get_logs",
|
|
200
|
+
annotations: READ_ONLY,
|
|
201
|
+
description:
|
|
202
|
+
"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), 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).",
|
|
203
|
+
inputSchema: {
|
|
204
|
+
since: z
|
|
205
|
+
.number()
|
|
206
|
+
.int()
|
|
207
|
+
.describe("Only return entries with seq greater than this (default 0: the whole buffer)")
|
|
208
|
+
.optional(),
|
|
209
|
+
wait_ms: z
|
|
210
|
+
.number()
|
|
211
|
+
.int()
|
|
212
|
+
.min(0)
|
|
213
|
+
.max(30000)
|
|
214
|
+
.describe("If nothing matches newer than `since`, wait up to this many milliseconds for new output (max 30000)")
|
|
215
|
+
.optional(),
|
|
216
|
+
level: z
|
|
217
|
+
.string()
|
|
218
|
+
.describe('Only return entries with one of these levels, comma-separated (e.g. "error" or "error,warn")')
|
|
219
|
+
.optional(),
|
|
220
|
+
contains: z
|
|
221
|
+
.string()
|
|
222
|
+
.describe("Only return entries whose text contains this substring (case-insensitive)")
|
|
223
|
+
.optional(),
|
|
224
|
+
client: z
|
|
225
|
+
.number()
|
|
226
|
+
.int()
|
|
227
|
+
.describe("Only return entries from this client id (default: every client's)")
|
|
228
|
+
.optional(),
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "get_stats",
|
|
233
|
+
annotations: READ_ONLY,
|
|
234
|
+
description:
|
|
235
|
+
"Performance statistics from a running app client. Start with `window`: a summary of the frames rebuilt in the last window_ms (default 5000, max 10000) - 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), and `worst`, the single most expensive frame with its ageMs, phase breakdown (jsMs/layoutMs/postLayoutMs/paintMs/hoverMs) and that frame's own layout activity (paraShapes, measureCalls, dirtiedNodes, cacheGets/cacheHits, nodesPainted). This is where jank shows: the smoothed figures below average a one-frame hitch away, the window keeps it. Typical flow: send_input a burst (typing, a drag), then get_stats - `frames: 0` means nothing was rebuilt in the window (idle app), which is different from all-fast. The window also carries rates for the GPU counters when it spans 2+ frames: 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. 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), paraShapes (paragraphs actually shaped, i.e. words the shared word cache did not have; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), wordHits (words answered from the shared word cache; hits high and paraShapes 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). 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), 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), 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), 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), 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).",
|
|
236
|
+
inputSchema: {
|
|
237
|
+
window_ms: z
|
|
238
|
+
.number()
|
|
239
|
+
.int()
|
|
240
|
+
.min(0)
|
|
241
|
+
.max(10000)
|
|
242
|
+
.describe("How far back the window summary looks, in ms (default 5000, max 10000)")
|
|
243
|
+
.optional(),
|
|
244
|
+
client: CLIENT_ARG,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "set_stats_overlay",
|
|
249
|
+
annotations: SETS_STATE,
|
|
250
|
+
description:
|
|
251
|
+
"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`.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
active: z.boolean().describe("true draws the overlay, false hides it"),
|
|
254
|
+
client: CLIENT_ARG,
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: "get_render_tree",
|
|
259
|
+
annotations: READ_ONLY,
|
|
260
|
+
description:
|
|
261
|
+
"Snapshot of a running app client's render tree: 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. Pass props: true to also get each node's current property values (JSX names, only values that differ from the defaults - so an empty/absent props object means everything is at its default) and, for nodes moved off their box by a rotate/scale/3D transform anywhere on their ancestor chain, `quad`: the four painted corners in window coordinates [x0,y0, x1,y1, x2,y2, x3,y3] (pre-transform top-left, top-right, bottom-right, bottom-left). The box is always the quad's axis-aligned bounds, so under a transform the box alone overstates the footprint - read the quad for where edges actually landed. Use props to answer 'is rotate/color/d applied right now' in one call instead of loading probe entries. 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=<its id>.",
|
|
262
|
+
inputSchema: {
|
|
263
|
+
root: z
|
|
264
|
+
.number()
|
|
265
|
+
.int()
|
|
266
|
+
.describe("Only return the subtree under this node id (default: the whole tree)")
|
|
267
|
+
.optional(),
|
|
268
|
+
depth: z
|
|
269
|
+
.number()
|
|
270
|
+
.int()
|
|
271
|
+
.describe("Levels of children to include below the root (default: unlimited; 0 = the root node only)")
|
|
272
|
+
.optional(),
|
|
273
|
+
query: z
|
|
274
|
+
.string()
|
|
275
|
+
.describe(
|
|
276
|
+
"Search instead of snapshot: return `matches`, nodes whose kind equals or text contains this " +
|
|
277
|
+
"(case-insensitive), each with a `path` of ancestor ids from the search root. Combine with `root` to " +
|
|
278
|
+
"scope the search; `depth` is ignored.",
|
|
279
|
+
)
|
|
280
|
+
.optional(),
|
|
281
|
+
props: z
|
|
282
|
+
.boolean()
|
|
283
|
+
.describe("Include each node's current off-default property values and, for transformed nodes, the painted quad")
|
|
284
|
+
.optional(),
|
|
285
|
+
client: CLIENT_ARG,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "get_snapshot",
|
|
290
|
+
annotations: READ_ONLY,
|
|
291
|
+
description:
|
|
292
|
+
"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 <texture> 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. This tool always returns the PNG; for pixel assertions in a script, the dev server's control API answers /snapshot?node=<id>&format=raw with the RGBA8 bytes instead (agents/debugging.md).",
|
|
293
|
+
inputSchema: {
|
|
294
|
+
nodeId: z
|
|
295
|
+
.number()
|
|
296
|
+
.int()
|
|
297
|
+
.describe("Id of the node to capture, from get_render_tree; prefer the smallest relevant node over the root"),
|
|
298
|
+
x: z
|
|
299
|
+
.number()
|
|
300
|
+
.int()
|
|
301
|
+
.describe("Crop rect left edge in captured-image pixels (requires y, width, height)")
|
|
302
|
+
.optional(),
|
|
303
|
+
y: z.number().int().describe("Crop rect top edge in captured-image pixels").optional(),
|
|
304
|
+
width: z.number().int().describe("Crop rect width in captured-image pixels").optional(),
|
|
305
|
+
height: z.number().int().describe("Crop rect height in captured-image pixels").optional(),
|
|
306
|
+
scale: z
|
|
307
|
+
.number()
|
|
308
|
+
.int()
|
|
309
|
+
.min(1)
|
|
310
|
+
.max(8)
|
|
311
|
+
.describe(
|
|
312
|
+
"Integer magnification, 1-8: each captured pixel becomes an NxN block (nearest-neighbour), so you see " +
|
|
313
|
+
"the actual rendered pixels enlarged. Combine with a crop; the scaled output is capped at 8192 px per side",
|
|
314
|
+
)
|
|
315
|
+
.optional(),
|
|
316
|
+
save_to: SAVE_TO_ARG,
|
|
317
|
+
client: CLIENT_ARG,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: "get_gpu_resources",
|
|
322
|
+
annotations: READ_ONLY,
|
|
323
|
+
description:
|
|
324
|
+
"Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount plus firstVertex/instanceCount when off their 0/1 defaults, depth, attribute layout, 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 <texture> 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.",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
label: z.string().describe("Keep only resources whose create label equals this").optional(),
|
|
327
|
+
client: CLIENT_ARG,
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: "get_texture",
|
|
332
|
+
annotations: READ_ONLY,
|
|
333
|
+
description:
|
|
334
|
+
"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=<id>&format=raw with the RGBA8 bytes instead (agents/debugging.md).",
|
|
335
|
+
inputSchema: {
|
|
336
|
+
id: z.number().int().describe("Texture id, from get_gpu_resources"),
|
|
337
|
+
x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
|
|
338
|
+
y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
|
|
339
|
+
width: z.number().int().describe("Crop rect width in texture pixels").optional(),
|
|
340
|
+
height: z.number().int().describe("Crop rect height in texture pixels").optional(),
|
|
341
|
+
scale: z
|
|
342
|
+
.number()
|
|
343
|
+
.int()
|
|
344
|
+
.min(1)
|
|
345
|
+
.max(8)
|
|
346
|
+
.describe(
|
|
347
|
+
"Integer magnification, 1-8: each texture pixel becomes an NxN block (nearest-neighbour). Combine with " +
|
|
348
|
+
"a crop; the scaled output is capped at 8192 px per side",
|
|
349
|
+
)
|
|
350
|
+
.optional(),
|
|
351
|
+
save_to: SAVE_TO_ARG,
|
|
352
|
+
client: CLIENT_ARG,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: "get_buffer",
|
|
357
|
+
annotations: READ_ONLY,
|
|
358
|
+
description:
|
|
359
|
+
"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.",
|
|
360
|
+
inputSchema: {
|
|
361
|
+
id: z.number().int().describe("Buffer id, from get_gpu_resources"),
|
|
362
|
+
offset: z.number().int().describe("Byte offset to start reading at (default 0)").optional(),
|
|
363
|
+
length: z.number().int().describe("Number of values to read (default: the rest of the buffer, capped)").optional(),
|
|
364
|
+
as: z.enum(["f32", "u16", "u8"]).describe("How to decode the bytes (default f32)").optional(),
|
|
365
|
+
client: CLIENT_ARG,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: "list_debug",
|
|
370
|
+
annotations: READ_ONLY,
|
|
371
|
+
description:
|
|
372
|
+
"List the debug commands the running app registered via registerDebug from srt:dev. Returns the command names; call one with call_debug. Empty when the app registered none.",
|
|
373
|
+
inputSchema: { client: CLIENT_ARG },
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "call_debug",
|
|
377
|
+
annotations: DRIVES_APP,
|
|
378
|
+
description:
|
|
379
|
+
"Call a debug command the running app registered via registerDebug from srt:dev, by name (from list_debug). `args` (any JSON value; omit it for none) is passed to the command's function as its single argument; 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.",
|
|
380
|
+
inputSchema: {
|
|
381
|
+
name: z.string().describe("Debug command name, from list_debug"),
|
|
382
|
+
args: z.any().describe("Argument passed to the command, any JSON value (default: none)").optional(),
|
|
383
|
+
client: CLIENT_ARG,
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: "reload",
|
|
388
|
+
description:
|
|
389
|
+
"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); pause_watch before an edit burst 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.",
|
|
390
|
+
inputSchema: {},
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "load",
|
|
394
|
+
description:
|
|
395
|
+
"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.",
|
|
396
|
+
inputSchema: {
|
|
397
|
+
entry: z.string().describe("App entry source file to load (relative paths resolve against the bridge's working directory, normally the project root)"),
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "mute_user_input",
|
|
402
|
+
annotations: SETS_STATE,
|
|
403
|
+
description:
|
|
404
|
+
"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.",
|
|
405
|
+
inputSchema: {},
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "unmute_user_input",
|
|
409
|
+
annotations: SETS_STATE,
|
|
410
|
+
description:
|
|
411
|
+
"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.",
|
|
412
|
+
inputSchema: {},
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
name: "pause_watch",
|
|
416
|
+
annotations: SETS_STATE,
|
|
417
|
+
description:
|
|
418
|
+
"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; your explicit reload still is. Call it before an edit burst; 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.",
|
|
419
|
+
inputSchema: {},
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: "resume_watch",
|
|
423
|
+
annotations: SETS_STATE,
|
|
424
|
+
description:
|
|
425
|
+
"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.",
|
|
426
|
+
inputSchema: {},
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: "set_time_scale",
|
|
430
|
+
annotations: SETS_STATE,
|
|
431
|
+
description:
|
|
432
|
+
"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.",
|
|
433
|
+
inputSchema: {
|
|
434
|
+
scale: z
|
|
435
|
+
.number()
|
|
436
|
+
.min(0)
|
|
437
|
+
.describe("Time scale: 0 = pause, 1 = normal, 0.5 = half speed, 2 = double speed"),
|
|
438
|
+
client: CLIENT_ARG,
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "step_frames",
|
|
443
|
+
annotations: DRIVES_APP,
|
|
444
|
+
description:
|
|
445
|
+
"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. 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.",
|
|
446
|
+
inputSchema: {
|
|
447
|
+
n: z.number().int().min(1).max(1000).describe("Number of frames to advance (1-1000)"),
|
|
448
|
+
client: CLIENT_ARG,
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: "send_input",
|
|
453
|
+
annotations: DRIVES_APP,
|
|
454
|
+
description:
|
|
455
|
+
"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'|'move'|'tap', x, y} for clicks and drags - coordinates in logical points, the same space get_render_tree reports; 'tap' is down+up with an optional holdMs between; 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; positive deltaY scrolls content down. Recipes: click a button = [{type:'pointer',action:'tap',x:400,y:300}]. Drag = down, then moves with delayMs 16 each, then up. 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.",
|
|
456
|
+
inputSchema: {
|
|
457
|
+
events: z.array(EVENT_ARG).min(1).max(200).describe("Event sequence, executed in order"),
|
|
458
|
+
client: CLIENT_ARG,
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
function clientParam(args: any): string {
|
|
464
|
+
return typeof args?.client === "number" ? `?client=${args.client}` : ""
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// The crop rect is all four of x/y/width/height or none. The server checks
|
|
468
|
+
// the same rule; catching it here hands the agent the rule without a round
|
|
469
|
+
// trip. Returns the error message, or undefined when the rect is well-formed.
|
|
470
|
+
function partialCrop(tool: string, args: any): string | undefined {
|
|
471
|
+
let given = ["x", "y", "width", "height"].filter((k) => typeof args?.[k] === "number").length
|
|
472
|
+
return given === 0 || given === 4 ? undefined : `${tool}: a crop needs all four of x, y, width, height`
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function callTool(name: string, args: any): Promise<ControlResult> {
|
|
476
|
+
switch (name) {
|
|
477
|
+
case "list_clients":
|
|
478
|
+
return control("/clients")
|
|
479
|
+
case "get_logs": {
|
|
480
|
+
let params = new URLSearchParams()
|
|
481
|
+
if (typeof args?.since === "number") params.set("since", String(args.since))
|
|
482
|
+
if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
|
|
483
|
+
if (typeof args?.level === "string") params.set("level", args.level)
|
|
484
|
+
if (typeof args?.contains === "string") params.set("contains", args.contains)
|
|
485
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
486
|
+
let qs = params.toString()
|
|
487
|
+
return control(qs ? `/logs?${qs}` : "/logs")
|
|
488
|
+
}
|
|
489
|
+
case "get_stats": {
|
|
490
|
+
let params = new URLSearchParams()
|
|
491
|
+
if (typeof args?.window_ms === "number") params.set("window", String(args.window_ms))
|
|
492
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
493
|
+
let qs = params.toString()
|
|
494
|
+
return control(qs ? `/stats?${qs}` : "/stats")
|
|
495
|
+
}
|
|
496
|
+
case "set_stats_overlay": {
|
|
497
|
+
if (typeof args?.active !== "boolean") return { ok: false, message: "set_stats_overlay requires active: true|false" }
|
|
498
|
+
let params = new URLSearchParams({ active: String(args.active) })
|
|
499
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
500
|
+
return control(`/stats?${params.toString()}`, "POST")
|
|
501
|
+
}
|
|
502
|
+
case "get_render_tree": {
|
|
503
|
+
let params = new URLSearchParams()
|
|
504
|
+
if (typeof args?.root === "number") params.set("root", String(args.root))
|
|
505
|
+
if (typeof args?.depth === "number") params.set("depth", String(args.depth))
|
|
506
|
+
if (typeof args?.query === "string") params.set("query", args.query)
|
|
507
|
+
if (args?.props === true) params.set("props", "true")
|
|
508
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
509
|
+
let qs = params.toString()
|
|
510
|
+
return control(qs ? `/tree?${qs}` : "/tree")
|
|
511
|
+
}
|
|
512
|
+
case "reload":
|
|
513
|
+
return control("/reload", "POST")
|
|
514
|
+
case "load": {
|
|
515
|
+
if (typeof args?.entry !== "string" || !args.entry) return { ok: false, message: "load requires an entry path" }
|
|
516
|
+
// Resolved here, against the bridge's cwd: the server would resolve
|
|
517
|
+
// against the project root (or the served file's directory), which
|
|
518
|
+
// the agent may not be sitting in.
|
|
519
|
+
return control("/load", "POST", { entry: resolve(args.entry) })
|
|
520
|
+
}
|
|
521
|
+
case "mute_user_input":
|
|
522
|
+
case "unmute_user_input": {
|
|
523
|
+
let active = name === "mute_user_input"
|
|
524
|
+
let result = await control(`/mute?active=${active}`, "POST")
|
|
525
|
+
if (result.ok) muted = active
|
|
526
|
+
return result
|
|
527
|
+
}
|
|
528
|
+
case "pause_watch":
|
|
529
|
+
case "resume_watch": {
|
|
530
|
+
let paused = name === "pause_watch"
|
|
531
|
+
let result = await control(`/watch?active=${!paused}`, "POST")
|
|
532
|
+
if (result.ok) watchPaused = paused
|
|
533
|
+
return result
|
|
534
|
+
}
|
|
535
|
+
case "get_snapshot": {
|
|
536
|
+
if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
|
|
537
|
+
let crop = partialCrop("get_snapshot", args)
|
|
538
|
+
if (crop) return { ok: false, message: crop }
|
|
539
|
+
let params = new URLSearchParams({ node: String(args.nodeId) })
|
|
540
|
+
for (let key of ["x", "y", "width", "height", "scale"]) {
|
|
541
|
+
if (typeof args?.[key] === "number") params.set(key, String(args[key]))
|
|
542
|
+
}
|
|
543
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
544
|
+
return control(`/snapshot?${params.toString()}`)
|
|
545
|
+
}
|
|
546
|
+
case "set_time_scale": {
|
|
547
|
+
if (typeof args?.scale !== "number" || !(args.scale >= 0)) {
|
|
548
|
+
return { ok: false, message: "set_time_scale requires scale >= 0" }
|
|
549
|
+
}
|
|
550
|
+
let params = new URLSearchParams({ scale: String(args.scale) })
|
|
551
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
552
|
+
return control(`/clock?${params.toString()}`, "POST")
|
|
553
|
+
}
|
|
554
|
+
case "step_frames": {
|
|
555
|
+
if (typeof args?.n !== "number" || !(args.n >= 1)) return { ok: false, message: "step_frames requires n >= 1" }
|
|
556
|
+
let params = new URLSearchParams({ step: String(args.n) })
|
|
557
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
558
|
+
return control(`/clock?${params.toString()}`, "POST")
|
|
559
|
+
}
|
|
560
|
+
case "send_input": {
|
|
561
|
+
if (!Array.isArray(args?.events) || args.events.length === 0)
|
|
562
|
+
return { ok: false, message: "send_input requires a non-empty events array" }
|
|
563
|
+
return control(`/input${clientParam(args)}`, "POST", { events: args.events })
|
|
564
|
+
}
|
|
565
|
+
case "get_gpu_resources": {
|
|
566
|
+
let params = new URLSearchParams()
|
|
567
|
+
if (typeof args?.label === "string") params.set("label", args.label)
|
|
568
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
569
|
+
let qs = params.toString()
|
|
570
|
+
return control(`/gpu${qs ? `?${qs}` : ""}`)
|
|
571
|
+
}
|
|
572
|
+
case "list_debug":
|
|
573
|
+
return control(`/debug${clientParam(args)}`)
|
|
574
|
+
case "call_debug": {
|
|
575
|
+
if (typeof args?.name !== "string") return { ok: false, message: "call_debug requires a command name" }
|
|
576
|
+
let params = new URLSearchParams({ name: args.name })
|
|
577
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
578
|
+
return control(`/debug?${params.toString()}`, "POST", args?.args)
|
|
579
|
+
}
|
|
580
|
+
case "get_texture": {
|
|
581
|
+
if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
|
|
582
|
+
let crop = partialCrop("get_texture", args)
|
|
583
|
+
if (crop) return { ok: false, message: crop }
|
|
584
|
+
let params = new URLSearchParams({ id: String(args.id) })
|
|
585
|
+
for (let key of ["x", "y", "width", "height", "scale"]) {
|
|
586
|
+
if (typeof args?.[key] === "number") params.set(key, String(args[key]))
|
|
587
|
+
}
|
|
588
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
589
|
+
return control(`/texture?${params.toString()}`)
|
|
590
|
+
}
|
|
591
|
+
case "get_buffer": {
|
|
592
|
+
if (typeof args?.id !== "number") return { ok: false, message: "get_buffer requires a numeric id" }
|
|
593
|
+
let params = new URLSearchParams({ id: String(args.id) })
|
|
594
|
+
if (typeof args?.offset === "number") params.set("offset", String(args.offset))
|
|
595
|
+
if (typeof args?.length === "number") params.set("length", String(args.length))
|
|
596
|
+
if (typeof args?.as === "string") params.set("as", args.as)
|
|
597
|
+
if (typeof args?.client === "number") params.set("client", String(args.client))
|
|
598
|
+
return control(`/buffer?${params.toString()}`)
|
|
599
|
+
}
|
|
600
|
+
default:
|
|
601
|
+
return { ok: false, message: `Unknown tool: ${name}` }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function toContent(name: string, result: ControlResult, args?: any): Promise<CallToolResult> {
|
|
606
|
+
if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
|
|
607
|
+
let content: CallToolResult["content"] = []
|
|
608
|
+
if (result.note) content.push({ type: "text", text: result.note })
|
|
609
|
+
// The bridge never asks for format=raw, so the reply carries the PNG; a
|
|
610
|
+
// reply without one is left as JSON rather than passed off as an image.
|
|
611
|
+
let image = name === "get_snapshot" || name === "get_texture" ? (result.body as ImageResponse) : null
|
|
612
|
+
if (image?.pngBase64) {
|
|
613
|
+
let { pngBase64, width, height } = image
|
|
614
|
+
let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents"
|
|
615
|
+
let text = `${label}: ${width}x${height} px`
|
|
616
|
+
// save_to is handled here in the bridge, not by the dev server: this
|
|
617
|
+
// process runs on the caller's machine, so the path lands where the
|
|
618
|
+
// agent expects it. The image content block alone is a dead end for
|
|
619
|
+
// that - the model sees the pixels but never the bytes.
|
|
620
|
+
if (typeof args?.save_to === "string") {
|
|
621
|
+
let path = resolve(args.save_to)
|
|
622
|
+
try {
|
|
623
|
+
await Bun.write(path, Buffer.from(pngBase64, "base64"))
|
|
624
|
+
text += `, saved to ${path}`
|
|
625
|
+
} catch (e) {
|
|
626
|
+
return { content: [{ type: "text", text: `Captured, but saving to ${path} failed: ${e}` }], isError: true }
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
content.push({ type: "image", data: pngBase64, mimeType: "image/png" }, { type: "text", text })
|
|
630
|
+
return { content }
|
|
631
|
+
}
|
|
632
|
+
content.push({ type: "text", text: JSON.stringify(result.body, null, 2) })
|
|
633
|
+
return { content }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Whether this bridge muted the user's input and has not unmuted, and
|
|
637
|
+
// whether it paused reload-on-save and has not resumed. Either outliving the
|
|
638
|
+
// bridge would leave the user locked out of their own client, or saving into
|
|
639
|
+
// nothing, so the bridge lifts both when the agent host closes the pipe or
|
|
640
|
+
// kills it.
|
|
641
|
+
let muted = false
|
|
642
|
+
let watchPaused = false
|
|
643
|
+
|
|
644
|
+
async function restoreOnExit() {
|
|
645
|
+
if (muted) {
|
|
646
|
+
muted = false
|
|
647
|
+
await control("/mute?active=false", "POST")
|
|
648
|
+
}
|
|
649
|
+
if (watchPaused) {
|
|
650
|
+
watchPaused = false
|
|
651
|
+
await control("/watch?active=true", "POST")
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Requests the host sent that have no answer yet, by id. The host closing
|
|
656
|
+
// stdin is its "done", but a request already read still owes its answer and
|
|
657
|
+
// stdout stays open, so exit waits for those first. Counted at the transport
|
|
658
|
+
// (entering onmessage, leaving with the response write) rather than in the
|
|
659
|
+
// tool handler: a request read from the pipe can sit undispatched when the
|
|
660
|
+
// pipe closes right behind it. Capped, so a wedged dev server cannot keep a
|
|
661
|
+
// closed bridge alive.
|
|
662
|
+
let pending = new Set<string | number>()
|
|
663
|
+
let drained: Array<() => void> = []
|
|
664
|
+
const EXIT_GRACE_MS = 5000
|
|
665
|
+
|
|
666
|
+
let exiting = false
|
|
667
|
+
async function shutdown() {
|
|
668
|
+
if (exiting) return
|
|
669
|
+
exiting = true
|
|
670
|
+
if (pending.size > 0) {
|
|
671
|
+
await Promise.race([
|
|
672
|
+
new Promise<void>((resolve) => drained.push(resolve)),
|
|
673
|
+
new Promise<void>((resolve) => setTimeout(resolve, EXIT_GRACE_MS)),
|
|
674
|
+
])
|
|
675
|
+
}
|
|
676
|
+
await restoreOnExit()
|
|
677
|
+
process.exit(0)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function main() {
|
|
681
|
+
let server = new McpServer({ name: "solidrt", version: CLI_VERSION })
|
|
682
|
+
|
|
683
|
+
for (let tool of TOOLS) {
|
|
684
|
+
server.registerTool(
|
|
685
|
+
tool.name,
|
|
686
|
+
{
|
|
687
|
+
description: tool.description,
|
|
688
|
+
inputSchema: tool.inputSchema,
|
|
689
|
+
annotations: { openWorldHint: false, ...tool.annotations },
|
|
690
|
+
},
|
|
691
|
+
async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args),
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let transport = new StdioServerTransport()
|
|
696
|
+
let send = transport.send.bind(transport)
|
|
697
|
+
transport.send = async (message: JSONRPCMessage) => {
|
|
698
|
+
await send(message)
|
|
699
|
+
if ("id" in message && message.id !== undefined && !("method" in message)) {
|
|
700
|
+
pending.delete(message.id)
|
|
701
|
+
if (pending.size === 0) for (let wake of drained.splice(0)) wake()
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// connect() installs the dispatching onmessage; the count wraps it.
|
|
705
|
+
await server.connect(transport)
|
|
706
|
+
let dispatch = transport.onmessage
|
|
707
|
+
transport.onmessage = (message: JSONRPCMessage) => {
|
|
708
|
+
if ("id" in message && "method" in message) pending.add(message.id)
|
|
709
|
+
dispatch?.(message)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
process.stdin.on("end", () => void shutdown())
|
|
713
|
+
for (let signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
|
|
714
|
+
process.on(signal, () => void shutdown())
|
|
715
|
+
}
|
|
716
|
+
// The stdin read keeps the process alive; it exits when the agent host
|
|
717
|
+
// closes the pipe (after answering the requests still pending and lifting
|
|
718
|
+
// any mute or watch pause it set, see above).
|
|
719
|
+
}
|