@solidrt/cli 0.0.50 → 0.0.52

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.
Files changed (98) hide show
  1. package/AGENTS.md +79 -36
  2. package/README.md +86 -15
  3. package/agents/assets.md +46 -0
  4. package/agents/debugging.md +294 -0
  5. package/dist/console.srtapp +94123 -73
  6. package/dist/server.js +3936 -0
  7. package/package.json +11 -10
  8. package/src/android/docs.md +21 -0
  9. package/src/android/main.ts +286 -0
  10. package/src/{bundler.ts → bundle/bundler.ts} +163 -76
  11. package/src/bundle/docs.md +12 -0
  12. package/src/bundle/main.ts +203 -0
  13. package/src/check/docs.md +10 -0
  14. package/src/check/main.ts +85 -0
  15. package/src/{commands/check.ts → check/typecheck.ts} +11 -34
  16. package/src/client/docs.md +9 -0
  17. package/src/client/main.ts +33 -0
  18. package/src/console/docs.md +14 -0
  19. package/src/console/main.ts +21 -0
  20. package/src/demo/docs.md +27 -0
  21. package/src/demo/main.ts +67 -0
  22. package/src/init/docs.md +11 -0
  23. package/src/{commands/init.ts → init/main.ts} +31 -25
  24. package/src/init/scaffold/AGENTS.md +98 -0
  25. package/src/init/scaffold/package.json +23 -0
  26. package/{scaffold → src/init/scaffold}/templates/components/index.tsx +2 -3
  27. package/{scaffold → src/init/scaffold}/templates/default/index.tsx +2 -3
  28. package/src/lib/args.ts +194 -0
  29. package/src/{artifacts.ts → lib/artifacts.ts} +41 -1
  30. package/src/{dev-dir.ts → lib/dev-dir.ts} +10 -8
  31. package/src/{fonts.ts → lib/fonts.ts} +12 -26
  32. package/src/lib/mode.ts +77 -0
  33. package/src/{project.ts → lib/project.ts} +109 -61
  34. package/src/lib/registry.ts +120 -0
  35. package/src/lib/server-bundle.ts +24 -0
  36. package/src/lib/usage.ts +117 -0
  37. package/src/lib/util.ts +36 -0
  38. package/src/main.ts +109 -31
  39. package/src/mcp/docs.md +22 -0
  40. package/src/mcp/main.ts +719 -0
  41. package/src/pack/docs.md +18 -0
  42. package/src/{pack-folder.ts → pack/layout.ts} +13 -22
  43. package/src/pack/main.ts +86 -0
  44. package/src/pack/trailer.ts +97 -0
  45. package/src/render/docs.md +19 -0
  46. package/src/render/main.ts +49 -0
  47. package/src/server/args.ts +126 -0
  48. package/src/server/binaries.ts +47 -0
  49. package/src/server/config.ts +54 -0
  50. package/{server → src/server}/control.ts +246 -78
  51. package/src/server/docs.md +51 -0
  52. package/src/server/line-editor.ts +200 -0
  53. package/src/server/main.ts +473 -0
  54. package/src/server/mode.ts +92 -0
  55. package/src/server/rebuild.ts +90 -0
  56. package/src/server/registry.ts +138 -0
  57. package/src/server/remap.ts +60 -0
  58. package/src/server/repl.ts +223 -0
  59. package/src/server/state.ts +54 -0
  60. package/{server → src/server}/tsconfig.json +1 -1
  61. package/{server → src/server}/tunnel.ts +6 -6
  62. package/src/server/watcher.ts +121 -0
  63. package/src/tool/main.ts +70 -0
  64. package/src/types/bundle.d.ts +24 -0
  65. package/src/types/control.d.ts +90 -0
  66. package/src/types/registry.d.ts +16 -0
  67. package/scaffold/AGENTS.md +0 -630
  68. package/scaffold/package.json +0 -22
  69. package/scaffold/templates/components/icon.tsx +0 -48
  70. package/scaffold/templates/default/icon.tsx +0 -48
  71. package/server/main.ts +0 -308
  72. package/server/rebuild.ts +0 -68
  73. package/server/remap.ts +0 -47
  74. package/server/state.ts +0 -93
  75. package/src/args.ts +0 -212
  76. package/src/bundle-cli.ts +0 -13
  77. package/src/commands/bundle.ts +0 -76
  78. package/src/commands/client.ts +0 -34
  79. package/src/commands/mcp.ts +0 -603
  80. package/src/commands/pack.ts +0 -65
  81. package/src/commands/render.ts +0 -24
  82. package/src/commands/server.ts +0 -73
  83. package/src/dev-android.ts +0 -176
  84. package/src/dev-client.ts +0 -29
  85. package/src/dev-server.ts +0 -302
  86. package/src/packer.ts +0 -103
  87. package/src/repl.ts +0 -233
  88. package/src/util.ts +0 -121
  89. package/src/watcher.ts +0 -69
  90. /package/src/{untyped-deps.d.ts → bundle/untyped-deps.d.ts} +0 -0
  91. /package/src/{prompt.ts → init/prompt.ts} +0 -0
  92. /package/{scaffold → src/init/scaffold}/gitignore +0 -0
  93. /package/{scaffold → src/init/scaffold}/icon.svg +0 -0
  94. /package/{scaffold → src/init/scaffold}/mcp.json +0 -0
  95. /package/{scaffold → src/init/scaffold}/tsconfig.json +0 -0
  96. /package/{server → src/server}/cache.ts +0 -0
  97. /package/{server → src/server}/proxy.ts +0 -0
  98. /package/{server → src/server}/qr.ts +0 -0
@@ -1,603 +0,0 @@
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 } from "@modelcontextprotocol/sdk/types.js"
12
- import { dirname, join, resolve } from "node:path"
13
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
14
- import { values, DEFAULT_DEV_PORT } from "../args"
15
- import { DEV_PORT } from "../dev-server"
16
- import { devDir } from "../dev-dir"
17
-
18
- // An explicit -s/--port pins the port for the bridge's lifetime. Otherwise
19
- // the port is resolved per tool call from the server registry, so one bridge
20
- // (started when the workspace opens, kept alive across server restarts)
21
- // follows whichever server is currently serving this project - and the
22
- // scaffold's mcp.json never carries a port.
23
- const FIXED_PORT = values.port !== undefined || values.session !== undefined ? DEV_PORT : null
24
-
25
- // The projectDir the bridge is working in: the nearest package.json above its
26
- // own cwd, the same rule srt applies to an entry (project.ts projectDirFor),
27
- // so both sides derive the same string.
28
- function findProjectDir(): string | null {
29
- let dir = process.cwd()
30
- while (true) {
31
- if (existsSync(join(dir, "package.json"))) return dir
32
- let parent = dirname(dir)
33
- if (parent === dir) return null
34
- dir = parent
35
- }
36
- }
37
-
38
- // The two sides of a projectDir comparison come from different processes
39
- // (the server's entry path, the bridge's cwd) and only agree by construction
40
- // on the directory, not the spelling: an editor-spawned bridge on Windows
41
- // keeps its parent's lower-case drive letter while a shell writes it upper
42
- // case, and 8.3 names, symlinks and subst drives are the same class. Compare
43
- // the canonical path, so the spelling never decides.
44
- function sameDir(a: string, b: string): boolean {
45
- if (a === b) return true
46
- try {
47
- return realpathSync.native(a) === realpathSync.native(b)
48
- } catch {
49
- return false
50
- }
51
- }
52
-
53
- // Only ESRCH means the process is gone. EPERM is a live process this bridge
54
- // may not signal (Windows reports it for other users' processes), and a
55
- // bare try/catch would drop that healthy server from the registry.
56
- function pidAlive(pid: number): boolean {
57
- try {
58
- process.kill(pid, 0)
59
- return true
60
- } catch (e: any) {
61
- return e?.code === "EPERM"
62
- }
63
- }
64
-
65
- type LiveRecord = { pid: number; port: number; projectDir: string }
66
-
67
- // The global server registry: every running dev server keeps a live.json in
68
- // ~/.solidrt/servers/<port>/ (see dev-server.ts writeLiveRecord). Unreadable or
69
- // malformed records are skipped, not fatal - the registry is a hint.
70
- function liveRecords(): LiveRecord[] {
71
- let root = devDir("servers")
72
- let names: string[]
73
- try {
74
- names = readdirSync(root)
75
- } catch {
76
- return []
77
- }
78
- let records: LiveRecord[] = []
79
- for (let name of names) {
80
- try {
81
- let record = JSON.parse(readFileSync(join(root, name, "live.json"), "utf8"))
82
- if (typeof record?.pid === "number" && typeof record?.port === "number" && typeof record?.projectDir === "string") {
83
- records.push(record)
84
- }
85
- } catch {}
86
- }
87
- return records
88
- }
89
-
90
- type PortResult = { ok: true; port: number } | { ok: false; message: string }
91
-
92
- async function resolvePort(): Promise<PortResult> {
93
- if (FIXED_PORT !== null) return { ok: true, port: FIXED_PORT }
94
- let project = findProjectDir()
95
- if (!project) {
96
- return {
97
- ok: false,
98
- message: `No package.json found above ${process.cwd()}, so no dev server can be resolved by project. Pass -s <N> or --port <N> to srt mcp.`,
99
- }
100
- }
101
- let records = liveRecords()
102
- let matches = records.filter((r) => sameDir(r.projectDir, project) && pidAlive(r.pid))
103
- if (matches.length > 1) {
104
- let ports = matches
105
- .map((r) => r.port)
106
- .sort((a, b) => a - b)
107
- .join(", ")
108
- return { ok: false, message: `${matches.length} dev servers are serving this project (ports ${ports}); pass -s <N> to srt mcp` }
109
- }
110
- if (matches.length === 0) {
111
- // A lookup by key that fails against a small table prints the table: an
112
- // empty registry, a dead pid and a record for another project are three
113
- // different problems, and the reader can only tell them apart if the
114
- // candidates are listed next to the key that was looked up.
115
- let listing =
116
- records.length === 0
117
- ? `Registry ${devDir("servers")}: no records.`
118
- : `Registry ${devDir("servers")}: ${records.length} record(s).\n` +
119
- records
120
- .map((r) => {
121
- let session = r.port - DEFAULT_DEV_PORT
122
- let flag = session >= 0 && session < 100 ? `-s ${session}` : `--port ${r.port}`
123
- return ` port ${r.port} (${flag}) pid ${r.pid} (${pidAlive(r.pid) ? "alive" : "dead"}) serving ${r.projectDir}`
124
- })
125
- .join("\n")
126
- return {
127
- ok: false,
128
- message: `No dev server for ${project}.\n${listing}\nStart one with srt run, or pin one of the servers above by passing its flag to srt mcp.`,
129
- }
130
- }
131
- let port = matches[0]!.port
132
- // The record is a hint; the server is authoritative. The probe catches a
133
- // stale record whose pid was reused by an unrelated process.
134
- try {
135
- let probe = await fetch(`http://127.0.0.1:${port}/__control__/clients`)
136
- let body: any = await probe.json().catch(() => null)
137
- if (!probe.ok || typeof body?.projectDir !== "string" || !sameDir(body.projectDir, project)) {
138
- return {
139
- ok: false,
140
- message: `The server on port ${port} is not serving ${project}${
141
- typeof body?.projectDir === "string" ? ` (it serves ${body.projectDir})` : ""
142
- }. Start one with srt run, or pass -s <N> to srt mcp.`,
143
- }
144
- }
145
- } catch {
146
- return {
147
- ok: false,
148
- message: `No dev server for ${project}: the registry lists port ${port} but nothing answers there. Start one with srt run.`,
149
- }
150
- }
151
- return { ok: true, port }
152
- }
153
-
154
- type ControlResult = { ok: true; body: any } | { ok: false; message: string }
155
-
156
- async function control(path: string, method: "GET" | "POST" = "GET", payload?: unknown): Promise<ControlResult> {
157
- let resolved = await resolvePort()
158
- if (!resolved.ok) return resolved
159
- let resp
160
- try {
161
- let init: RequestInit = { method }
162
- if (payload !== undefined) {
163
- init.headers = { "content-type": "application/json" }
164
- init.body = JSON.stringify(payload)
165
- }
166
- resp = await fetch(`http://127.0.0.1:${resolved.port}/__control__${path}`, init)
167
- } catch {
168
- return {
169
- ok: false,
170
- message: "Dev server not running. Start it in the project first: srt run src/index.tsx (or srt server)",
171
- }
172
- }
173
- let body: any = null
174
- try {
175
- body = await resp.json()
176
- } catch {}
177
- if (!resp.ok) return { ok: false, message: String(body?.error ?? `Dev server responded with HTTP ${resp.status}`) }
178
- return { ok: true, body }
179
- }
180
-
181
- let CLIENT_ARG = z
182
- .number()
183
- .int()
184
- .describe("Client id from list_clients (default: the only connected client; required when several are connected)")
185
- .optional()
186
-
187
- let SAVE_TO_ARG = z
188
- .string()
189
- .describe(
190
- "Also write the PNG to this file path (relative paths resolve against the project root; parent directories are created)",
191
- )
192
- .optional()
193
-
194
- // readOnly marks tools that only inspect state; it is surfaced as the
195
- // MCP-standard readOnlyHint annotation so agent harnesses that honor it can
196
- // auto-approve the inspection majority. load, reload, call_debug, and
197
- // send_input mutate the running app and keep the default hints (destructive,
198
- // not idempotent);
199
- // `annotations` overrides those defaults where a mutating tool is benign
200
- // (watch: a reversible, idempotent toggle). Every tool gets
201
- // openWorldHint: false - the bridge only ever talks to the local dev server.
202
- let TOOLS: {
203
- name: string
204
- description: string
205
- inputSchema: Record<string, z.ZodTypeAny>
206
- readOnly?: boolean
207
- annotations?: { destructiveHint?: boolean; idempotentHint?: boolean }
208
- }[] = [
209
- {
210
- name: "list_clients",
211
- readOnly: true,
212
- description:
213
- "List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors), `entry` (the app source file this server currently serves and rebuilds - check it matches the app you intend to drive before acting, since the dev port is fixed and a `load` moves the entry mid-session), `projectDir` (the project root the server was started in), and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime, and `queries` - the dev-tool query kinds that client's runtime answers (clock, input, snapshot, tree, ...). Check `queries` before planning a verification strategy: a client whose list lacks \"input\" predates send_input, one that lacks \"clock\" predates set_time_scale/step_frames (an empty list means the runtime predates the advertisement itself). Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
214
- inputSchema: {},
215
- },
216
- {
217
- name: "get_logs",
218
- readOnly: true,
219
- description:
220
- "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` to filter, e.g. level \"error\" to skip chatty output.",
221
- inputSchema: {
222
- since: z
223
- .number()
224
- .int()
225
- .describe("Only return entries with seq greater than this (default 0: the whole buffer)")
226
- .optional(),
227
- wait_ms: z
228
- .number()
229
- .int()
230
- .describe("If nothing matches newer than `since`, wait up to this many milliseconds for new output (max 30000)")
231
- .optional(),
232
- level: z
233
- .string()
234
- .describe('Only return entries with one of these levels, comma-separated (e.g. "error" or "error,warn")')
235
- .optional(),
236
- contains: z
237
- .string()
238
- .describe("Only return entries whose text contains this substring (case-insensitive)")
239
- .optional(),
240
- },
241
- },
242
- {
243
- name: "get_stats",
244
- readOnly: true,
245
- description:
246
- "Performance statistics from a running app client: 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). GPU-side health, read live at query time (absent when no engine is running): rasterQueue (raster commands sent but not yet executed; stuck nonzero means the raster thread is backlogged - the state where fps and frameMs go blind because no frames complete), 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/gpuPassMs (cumulative shader/pipeline target renders on the raster thread and the wall time they took 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; the ms figure is raster-thread occupancy issuing the passes, not GPU-side duration), 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).",
247
- inputSchema: { client: CLIENT_ARG },
248
- },
249
- {
250
- name: "get_render_tree",
251
- readOnly: true,
252
- description:
253
- "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>.",
254
- inputSchema: {
255
- root: z
256
- .number()
257
- .int()
258
- .describe("Only return the subtree under this node id (default: the whole tree)")
259
- .optional(),
260
- depth: z
261
- .number()
262
- .int()
263
- .describe("Levels of children to include below the root (default: unlimited; 0 = the root node only)")
264
- .optional(),
265
- query: z
266
- .string()
267
- .describe(
268
- "Search instead of snapshot: return `matches`, nodes whose kind equals or text contains this " +
269
- "(case-insensitive), each with a `path` of ancestor ids from the search root. Combine with `root` to " +
270
- "scope the search; `depth` is ignored.",
271
- )
272
- .optional(),
273
- props: z
274
- .boolean()
275
- .describe("Include each node's current off-default property values and, for transformed nodes, the painted quad")
276
- .optional(),
277
- client: CLIENT_ARG,
278
- },
279
- },
280
- {
281
- name: "get_snapshot",
282
- readOnly: true,
283
- description:
284
- "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.",
285
- inputSchema: {
286
- nodeId: z
287
- .number()
288
- .int()
289
- .describe("Id of the node to capture, from get_render_tree; prefer the smallest relevant node over the root"),
290
- x: z
291
- .number()
292
- .int()
293
- .describe("Crop rect left edge in captured-image pixels (requires y, width, height)")
294
- .optional(),
295
- y: z.number().int().describe("Crop rect top edge in captured-image pixels").optional(),
296
- width: z.number().int().describe("Crop rect width in captured-image pixels").optional(),
297
- height: z.number().int().describe("Crop rect height in captured-image pixels").optional(),
298
- scale: z
299
- .number()
300
- .int()
301
- .min(1)
302
- .max(8)
303
- .describe(
304
- "Integer magnification, 1-8: each captured pixel becomes an NxN block (nearest-neighbour), so you see " +
305
- "the actual rendered pixels enlarged. Combine with a crop; the scaled output is capped at 8192 px per side",
306
- )
307
- .optional(),
308
- save_to: SAVE_TO_ARG,
309
- client: CLIENT_ARG,
310
- },
311
- },
312
- {
313
- name: "get_gpu_resources",
314
- readOnly: true,
315
- description:
316
- "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/passMs, cumulative per-target render count and raster-thread wall time in whole ms: when get_stats shows gpuPasses 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.",
317
- inputSchema: { client: CLIENT_ARG },
318
- },
319
- {
320
- name: "get_texture",
321
- readOnly: true,
322
- description:
323
- "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.",
324
- inputSchema: {
325
- id: z.number().int().describe("Texture id, from get_gpu_resources"),
326
- x: z.number().int().describe("Crop rect left edge in texture pixels (requires y, width, height)").optional(),
327
- y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
328
- width: z.number().int().describe("Crop rect width in texture pixels").optional(),
329
- height: z.number().int().describe("Crop rect height in texture pixels").optional(),
330
- scale: z
331
- .number()
332
- .int()
333
- .min(1)
334
- .max(8)
335
- .describe(
336
- "Integer magnification, 1-8: each texture pixel becomes an NxN block (nearest-neighbour). Combine with " +
337
- "a crop; the scaled output is capped at 8192 px per side",
338
- )
339
- .optional(),
340
- save_to: SAVE_TO_ARG,
341
- client: CLIENT_ARG,
342
- },
343
- },
344
- {
345
- name: "get_buffer",
346
- readOnly: true,
347
- description:
348
- "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.",
349
- inputSchema: {
350
- id: z.number().int().describe("Buffer id, from get_gpu_resources"),
351
- offset: z.number().int().describe("Byte offset to start reading at (default 0)").optional(),
352
- length: z.number().int().describe("Number of values to read (default: the rest of the buffer, capped)").optional(),
353
- as: z.enum(["f32", "u16", "u8"]).describe("How to decode the bytes (default f32)").optional(),
354
- client: CLIENT_ARG,
355
- },
356
- },
357
- {
358
- name: "list_debug",
359
- readOnly: true,
360
- description:
361
- "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.",
362
- inputSchema: { client: CLIENT_ARG },
363
- },
364
- {
365
- name: "call_debug",
366
- description:
367
- "Call a debug command the running app registered via registerDebug from srt:dev, by name (from list_debug). `args` 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.",
368
- inputSchema: {
369
- name: z.string().describe("Debug command name, from list_debug"),
370
- args: z.record(z.string(), z.any()).describe("Argument object passed to the command (default: none)").optional(),
371
- client: CLIENT_ARG,
372
- },
373
- },
374
- {
375
- name: "reload",
376
- description:
377
- "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. A successful reload re-enables the file watcher if you paused it with the watch tool. Follow with get_logs to see runtime output from the reloaded app.",
378
- inputSchema: {},
379
- },
380
- {
381
- name: "load",
382
- description:
383
- "Load an app entry: bundle the given .tsx/.jsx source file and push it to every connected client, replacing whatever is running. Use it when the dev server has no app loaded yet, or to switch to a different app; later reload calls rebuild this entry. Returns the number of clients loaded, or a build error if the source failed to compile. A successful load re-enables the file watcher if you paused it with the watch tool.",
384
- inputSchema: {
385
- entry: z.string().describe("App entry source file to load (relative paths resolve against the project root)"),
386
- },
387
- },
388
- {
389
- name: "set_time_scale",
390
- annotations: { destructiveHint: false, idempotentHint: true },
391
- description:
392
- "Control a running app client's clock. scale=0 freezes app time: onFrame/requestAnimationFrame stop being delivered, setTimeout/setInterval freeze, performance.now() holds still, and the picture stops - 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. Date.now() stays wall time throughout. The scale is client runtime state: it survives across your snapshots but resets to 1 on reload/load 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.",
393
- inputSchema: {
394
- scale: z
395
- .number()
396
- .min(0)
397
- .describe("Time scale: 0 = pause, 1 = normal, 0.5 = half speed, 2 = double speed"),
398
- client: CLIENT_ARG,
399
- },
400
- },
401
- {
402
- name: "step_frames",
403
- annotations: { destructiveHint: false },
404
- description:
405
- "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.",
406
- inputSchema: {
407
- n: z.number().int().min(1).max(1000).describe("Number of frames to advance (1-1000)"),
408
- client: CLIENT_ARG,
409
- },
410
- },
411
- {
412
- name: "send_input",
413
- description:
414
- "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.",
415
- inputSchema: {
416
- events: z
417
- .array(
418
- z.object({
419
- type: z.enum(["key", "pointer", "wheel", "text"]),
420
- action: z.enum(["down", "up", "move", "tap"]).optional(),
421
- key: z.string().optional().describe("W3C key name, required for type key"),
422
- text: z.string().optional().describe("Text to enter, required for type text"),
423
- x: z.number().optional().describe("Logical points, required for pointer and wheel"),
424
- y: z.number().optional().describe("Logical points, required for pointer and wheel"),
425
- deltaX: z.number().optional(),
426
- deltaY: z.number().optional(),
427
- button: z.number().int().min(0).max(4).optional(),
428
- pointerType: z.enum(["mouse", "touch"]).optional(),
429
- delayMs: z.number().int().min(0).max(5000).optional().describe("Wait before this event"),
430
- holdMs: z.number().int().min(0).max(5000).optional().describe("Tap only: time between down and up"),
431
- shift: z.boolean().optional(),
432
- ctrl: z.boolean().optional(),
433
- alt: z.boolean().optional(),
434
- meta: z.boolean().optional(),
435
- }),
436
- )
437
- .min(1)
438
- .max(200)
439
- .describe("Event sequence, executed in order"),
440
- client: CLIENT_ARG,
441
- },
442
- },
443
- {
444
- name: "watch",
445
- annotations: { destructiveHint: false, idempotentHint: true },
446
- description:
447
- "Pause or resume the dev server's automatic reload-on-save. The srt file watcher pushes a rebuild whenever app source changes on disk; call watch with enabled: false BEFORE creating or editing source files so your half-finished work is not pushed to the user's screens mid-burst, then apply everything with one explicit reload (a successful reload or load re-enables the watcher, so pause again before the next burst of file changes). The human's own saves auto-reload only while the watcher is enabled, so do not leave it paused when you stop working.",
448
- inputSchema: {
449
- enabled: z.boolean().describe("false pauses auto-reload-on-save, true resumes it"),
450
- },
451
- },
452
- ]
453
-
454
- function clientParam(args: any): string {
455
- return typeof args?.client === "number" ? `?client=${args.client}` : ""
456
- }
457
-
458
- async function callTool(name: string, args: any): Promise<ControlResult> {
459
- switch (name) {
460
- case "list_clients":
461
- return control("/clients")
462
- case "get_logs": {
463
- let params = new URLSearchParams()
464
- if (typeof args?.since === "number") params.set("since", String(args.since))
465
- if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
466
- if (typeof args?.level === "string") params.set("level", args.level)
467
- if (typeof args?.contains === "string") params.set("contains", args.contains)
468
- let qs = params.toString()
469
- return control(qs ? `/logs?${qs}` : "/logs")
470
- }
471
- case "get_stats":
472
- return control(`/stats${clientParam(args)}`)
473
- case "get_render_tree": {
474
- let params = new URLSearchParams()
475
- if (typeof args?.root === "number") params.set("root", String(args.root))
476
- if (typeof args?.depth === "number") params.set("depth", String(args.depth))
477
- if (typeof args?.query === "string") params.set("query", args.query)
478
- if (args?.props === true) params.set("props", "true")
479
- if (typeof args?.client === "number") params.set("client", String(args.client))
480
- let qs = params.toString()
481
- return control(qs ? `/tree?${qs}` : "/tree")
482
- }
483
- case "reload":
484
- return control("/reload", "POST")
485
- case "load": {
486
- if (typeof args?.entry !== "string" || !args.entry) return { ok: false, message: "load requires an entry path" }
487
- // Resolved here in the bridge: this process runs at the project root,
488
- // the dev server may not.
489
- return control("/load", "POST", { entry: resolve(args.entry) })
490
- }
491
- case "watch": {
492
- if (typeof args?.enabled !== "boolean") return { ok: false, message: "watch requires enabled: true or false" }
493
- return control("/watch", "POST", { enabled: args.enabled })
494
- }
495
- case "get_snapshot": {
496
- if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
497
- let params = new URLSearchParams({ node: String(args.nodeId) })
498
- for (let key of ["x", "y", "width", "height", "scale"]) {
499
- if (typeof args?.[key] === "number") params.set(key, String(args[key]))
500
- }
501
- if (typeof args?.client === "number") params.set("client", String(args.client))
502
- return control(`/snapshot?${params.toString()}`)
503
- }
504
- case "set_time_scale": {
505
- if (typeof args?.scale !== "number" || !(args.scale >= 0)) {
506
- return { ok: false, message: "set_time_scale requires scale >= 0" }
507
- }
508
- let params = new URLSearchParams({ scale: String(args.scale) })
509
- if (typeof args?.client === "number") params.set("client", String(args.client))
510
- return control(`/clock?${params.toString()}`, "POST")
511
- }
512
- case "step_frames": {
513
- if (typeof args?.n !== "number" || !(args.n >= 1)) return { ok: false, message: "step_frames requires n >= 1" }
514
- let params = new URLSearchParams({ step: String(args.n) })
515
- if (typeof args?.client === "number") params.set("client", String(args.client))
516
- return control(`/clock?${params.toString()}`, "POST")
517
- }
518
- case "send_input": {
519
- if (!Array.isArray(args?.events) || args.events.length === 0)
520
- return { ok: false, message: "send_input requires a non-empty events array" }
521
- return control(`/input${clientParam(args)}`, "POST", { events: args.events })
522
- }
523
- case "get_gpu_resources":
524
- return control(`/gpu${clientParam(args)}`)
525
- case "list_debug":
526
- return control(`/debug${clientParam(args)}`)
527
- case "call_debug": {
528
- if (typeof args?.name !== "string") return { ok: false, message: "call_debug requires a command name" }
529
- let params = new URLSearchParams({ name: args.name })
530
- if (typeof args?.client === "number") params.set("client", String(args.client))
531
- return control(`/debug?${params.toString()}`, "POST", args?.args)
532
- }
533
- case "get_texture": {
534
- if (typeof args?.id !== "number") return { ok: false, message: "get_texture requires a numeric id" }
535
- let params = new URLSearchParams({ id: String(args.id) })
536
- for (let key of ["x", "y", "width", "height", "scale"]) {
537
- if (typeof args?.[key] === "number") params.set(key, String(args[key]))
538
- }
539
- if (typeof args?.client === "number") params.set("client", String(args.client))
540
- return control(`/texture?${params.toString()}`)
541
- }
542
- case "get_buffer": {
543
- if (typeof args?.id !== "number") return { ok: false, message: "get_buffer requires a numeric id" }
544
- let params = new URLSearchParams({ id: String(args.id) })
545
- if (typeof args?.offset === "number") params.set("offset", String(args.offset))
546
- if (typeof args?.length === "number") params.set("length", String(args.length))
547
- if (typeof args?.as === "string") params.set("as", args.as)
548
- if (typeof args?.client === "number") params.set("client", String(args.client))
549
- return control(`/buffer?${params.toString()}`)
550
- }
551
- default:
552
- return { ok: false, message: `Unknown tool: ${name}` }
553
- }
554
- }
555
-
556
- async function toContent(name: string, result: ControlResult, args?: any): Promise<CallToolResult> {
557
- if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
558
- if (name === "get_snapshot" || name === "get_texture") {
559
- let { pngBase64, width, height } = result.body
560
- let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents"
561
- let text = `${label}: ${width}x${height} px`
562
- // save_to is handled here in the bridge, not by the dev server: this
563
- // process runs on the caller's machine, so the path lands where the
564
- // agent expects it. The image content block alone is a dead end for
565
- // that - the model sees the pixels but never the bytes.
566
- if (typeof args?.save_to === "string") {
567
- let path = resolve(args.save_to)
568
- try {
569
- await Bun.write(path, Buffer.from(pngBase64, "base64"))
570
- text += `, saved to ${path}`
571
- } catch (e) {
572
- return { content: [{ type: "text", text: `Captured, but saving to ${path} failed: ${e}` }], isError: true }
573
- }
574
- }
575
- return {
576
- content: [
577
- { type: "image", data: pngBase64, mimeType: "image/png" },
578
- { type: "text", text },
579
- ],
580
- }
581
- }
582
- return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
583
- }
584
-
585
- export async function runMcpCommand() {
586
- let server = new McpServer({ name: "solidrt", version: "0.0.0" })
587
-
588
- for (let tool of TOOLS) {
589
- server.registerTool(
590
- tool.name,
591
- {
592
- description: tool.description,
593
- inputSchema: tool.inputSchema,
594
- annotations: { readOnlyHint: !!tool.readOnly, openWorldHint: false, ...tool.annotations },
595
- },
596
- async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args),
597
- )
598
- }
599
-
600
- // The stdin read keeps the process alive; it exits when the agent host
601
- // closes the pipe.
602
- await server.connect(new StdioServerTransport())
603
- }