@solidrt/cli 0.0.24 → 0.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/cli",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -17,17 +17,18 @@
17
17
  "@babel/core": "^7.0.0",
18
18
  "@babel/plugin-syntax-jsx": "^7.0.0",
19
19
  "@babel/preset-typescript": "^7.0.0",
20
+ "@modelcontextprotocol/sdk": "^1.29.0",
20
21
  "babel-preset-solid": "2.0.0-beta.15",
21
22
  "bonjour-service": "^1.4.0",
22
23
  "qrcode-generator": "^2.0.4"
23
24
  },
24
25
  "optionalDependencies": {
25
- "@solidrt/darwin-arm64": "0.0.24",
26
- "@solidrt/linux-x64-gnu": "0.0.24",
27
- "@solidrt/win32-x64-msvc": "0.0.24"
26
+ "@solidrt/darwin-arm64": "0.0.25",
27
+ "@solidrt/linux-x64-gnu": "0.0.25",
28
+ "@solidrt/win32-x64-msvc": "0.0.25"
28
29
  },
29
30
  "peerDependencies": {
30
- "@solidrt/core": "0.0.24",
31
+ "@solidrt/core": "0.0.25",
31
32
  "typescript": "^6"
32
33
  },
33
34
  "devDependencies": {
@@ -84,3 +84,19 @@ Authoritative references ship inside the installed packages - read them:
84
84
  - bunx srt render src/index.tsx --size 480x640 --duration 1 --fps 2 - headless
85
85
  render to PNG frames (proves it renders; see the cli AGENTS.md for where the
86
86
  frames land)
87
+
88
+ ## MCP: inspect the running app
89
+
90
+ The project ships an MCP server (.mcp.json, `srt mcp`) that talks to the dev
91
+ server `bunx srt run` starts. When it is loaded in your environment, prefer
92
+ its tools over guessing at runtime state:
93
+
94
+ - list_clients: connected app clients, their platform and runtime capabilities
95
+ - get_logs: console output and runtime errors (seq cursor; `wait_ms` long-poll
96
+ to catch output right after a reload)
97
+ - get_render_tree: what the app actually rendered - node kinds, text, and
98
+ window-relative boxes
99
+ - get_stats: fps, CPU/memory, frame phase timings, setProperty rate
100
+
101
+ The tools need a running app: if list_clients is empty, ask the user to start
102
+ `bunx srt run src/index.tsx`.
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "solidrt": {
4
+ "command": "bun",
5
+ "args": ["node_modules/@solidrt/cli/bin/srt", "mcp"]
6
+ }
7
+ }
8
+ }
@@ -9,12 +9,12 @@
9
9
  "android": "srt client --android"
10
10
  },
11
11
  "dependencies": {
12
- "@solidrt/core": "0.0.24",
13
- "@solidrt/components": "0.0.24"
12
+ "@solidrt/core": "0.0.25",
13
+ "@solidrt/components": "0.0.25"
14
14
  },
15
15
  "devDependencies": {
16
- "@solidrt/cli": "0.0.24",
17
- "@solidrt/flux-types": "0.0.24",
16
+ "@solidrt/cli": "0.0.25",
17
+ "@solidrt/flux-types": "0.0.25",
18
18
  "typescript": "^6"
19
19
  }
20
20
  }
package/src/args.ts CHANGED
@@ -75,6 +75,7 @@ Commands:
75
75
  bundle <file> Transpile TS/JS/TSX/JSX to JS or bytecode
76
76
  render <file.tsx|jsx> Replay a script (optional) and render frames for video generation
77
77
  pack <file> Bundle + compile to a standalone executable (experimental)
78
+ mcp MCP server (stdio) exposing the running dev server to coding agents
78
79
 
79
80
  init options:
80
81
  -t, --template <name> Start from a named template (skips the interactive picker)
@@ -16,6 +16,7 @@ const TEMPLATE_FILES: Array<{ from: string; to: string }> = [
16
16
  { from: "package.json", to: "package.json" },
17
17
  { from: "tsconfig.json", to: "tsconfig.json" },
18
18
  { from: "gitignore", to: ".gitignore" },
19
+ { from: "mcp.json", to: ".mcp.json" },
19
20
  { from: "AGENTS.md", to: "AGENTS.md" },
20
21
  ]
21
22
 
@@ -0,0 +1,128 @@
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 { DEV_PORT } from "../dev-server"
9
+
10
+ const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
11
+
12
+ type ControlResult = { ok: true; body: any } | { ok: false; message: string }
13
+
14
+ async function control(path: string): Promise<ControlResult> {
15
+ let resp
16
+ try {
17
+ resp = await fetch(CONTROL_BASE + path)
18
+ } catch {
19
+ return {
20
+ ok: false,
21
+ message: "Dev server not running. Start it in the project first: srt run src/index.tsx (or srt server)",
22
+ }
23
+ }
24
+ let body: any = null
25
+ try {
26
+ body = await resp.json()
27
+ } catch {}
28
+ if (!resp.ok) return { ok: false, message: String(body?.error ?? `Dev server responded with HTTP ${resp.status}`) }
29
+ return { ok: true, body }
30
+ }
31
+
32
+ let TOOLS = [
33
+ {
34
+ name: "list_clients",
35
+ description:
36
+ "List the app clients connected to the SolidRT dev server. Each entry has id (pass it as `client` to the other tools), platform, runtime version, and the capability names compiled into that client's runtime.",
37
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
38
+ },
39
+ {
40
+ name: "get_logs",
41
+ description:
42
+ "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text) plus `latest`, the newest seq. 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.",
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: {
46
+ since: {
47
+ type: "integer",
48
+ description: "Only return entries with seq greater than this (default 0: the whole buffer)",
49
+ },
50
+ wait_ms: {
51
+ type: "integer",
52
+ description: "If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)",
53
+ },
54
+ },
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ {
59
+ name: "get_stats",
60
+ description:
61
+ "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.",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
66
+ },
67
+ additionalProperties: false,
68
+ },
69
+ },
70
+ {
71
+ name: "get_render_tree",
72
+ description:
73
+ "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.",
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {
77
+ client: { type: "integer", description: "Client id from list_clients (default: the only connected client)" },
78
+ },
79
+ additionalProperties: false,
80
+ },
81
+ },
82
+ ]
83
+
84
+ function clientParam(args: any): string {
85
+ return typeof args?.client === "number" ? `?client=${args.client}` : ""
86
+ }
87
+
88
+ async function callTool(name: string, args: any): Promise<ControlResult> {
89
+ switch (name) {
90
+ case "list_clients":
91
+ return control("/clients")
92
+ case "get_logs": {
93
+ let params = new URLSearchParams()
94
+ if (typeof args?.since === "number") params.set("since", String(args.since))
95
+ if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
96
+ let qs = params.toString()
97
+ return control(qs ? `/logs?${qs}` : "/logs")
98
+ }
99
+ case "get_stats":
100
+ return control(`/stats${clientParam(args)}`)
101
+ case "get_render_tree":
102
+ return control(`/tree${clientParam(args)}`)
103
+ default:
104
+ return { ok: false, message: `Unknown tool: ${name}` }
105
+ }
106
+ }
107
+
108
+ export async function runMcpCommand() {
109
+ let { Server } = await import("@modelcontextprotocol/sdk/server/index.js")
110
+ let { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
111
+ let { ListToolsRequestSchema, CallToolRequestSchema } = await import("@modelcontextprotocol/sdk/types.js")
112
+
113
+ let server = new Server({ name: "solidrt", version: "0.0.0" }, { capabilities: { tools: {} } })
114
+
115
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
116
+
117
+ server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
118
+ let result = await callTool(request.params.name, request.params.arguments ?? {})
119
+ if (!result.ok) {
120
+ return { content: [{ type: "text", text: result.message }], isError: true }
121
+ }
122
+ return { content: [{ type: "text", text: JSON.stringify(result.body, null, 2) }] }
123
+ })
124
+
125
+ // The stdin read keeps the process alive; it exits when the agent host
126
+ // closes the pipe.
127
+ await server.connect(new StdioServerTransport())
128
+ }
package/src/control.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { state } from "./util"
2
+
3
+ // The control API under /__control__/: read-only introspection of connected
4
+ // app clients, served next to the file routes. The MCP bridge (srt mcp) is the
5
+ // primary consumer. Two shapes: server-held data answered directly (clients,
6
+ // logs) and queries forwarded to a client over its websocket and correlated
7
+ // back by id (tree, stats).
8
+
9
+ export type LogEntry = { seq: number; at: number; client: number; level: string; text: string }
10
+
11
+ // Ring buffer of forwarded client logs. Capped so a chatty app cannot grow the
12
+ // server without bound; readers page through it with the `since` cursor.
13
+ const LOG_CAP = 2000
14
+ const QUERY_TIMEOUT_MS = 5000
15
+ const MAX_WAIT_MS = 30000
16
+
17
+ let logs: LogEntry[] = []
18
+ let logSeq = 0
19
+ // Pending long-poll wakeups (see handleLogs). Flushed on every append; a
20
+ // waiter that already timed out resolves again harmlessly.
21
+ let logWaiters: Array<() => void> = []
22
+
23
+ let nextQueryId = 1
24
+ let pendingQueries = new Map<number, (msg: any) => void>()
25
+
26
+ /// A `log` message arrived from a client: buffer it and wake long-polls.
27
+ export function appendLog(client: number, level: string, text: string) {
28
+ logs.push({ seq: ++logSeq, at: Date.now(), client, level, text })
29
+ if (logs.length > LOG_CAP) logs.splice(0, logs.length - LOG_CAP)
30
+ let waiters = logWaiters
31
+ logWaiters = []
32
+ for (let wake of waiters) wake()
33
+ }
34
+
35
+ /// A `result` message arrived from a client: hand it to the awaiting query.
36
+ export function resolveQuery(msg: { id?: number }) {
37
+ if (typeof msg.id !== "number") return
38
+ let resolve = pendingQueries.get(msg.id)
39
+ if (resolve) {
40
+ pendingQueries.delete(msg.id)
41
+ resolve(msg)
42
+ }
43
+ }
44
+
45
+ function clientList() {
46
+ return [...state.clients.values()].map((info) => ({
47
+ id: info.id,
48
+ platform: info.platform,
49
+ version: info.version,
50
+ capabilities: info.capabilities,
51
+ }))
52
+ }
53
+
54
+ // Resolve the target client for a query: an explicit ?client=<id>, or the only
55
+ // connected client when the parameter is omitted.
56
+ function findClient(param: string | null): { ws: any } | { error: Response } {
57
+ let entries = [...state.clients.entries()]
58
+ if (param === null) {
59
+ if (entries.length === 1) return { ws: entries[0]![0] }
60
+ if (entries.length === 0) return { error: Response.json({ error: "No connected clients" }, { status: 503 }) }
61
+ return { error: Response.json({ error: "Multiple clients connected; pass ?client=<id>" }, { status: 400 }) }
62
+ }
63
+ let id = parseInt(param, 10)
64
+ let entry = entries.find(([, info]) => info.id === id)
65
+ if (!entry) return { error: Response.json({ error: `No client with id ${param}` }, { status: 404 }) }
66
+ return { ws: entry[0] }
67
+ }
68
+
69
+ async function handleQuery(url: URL, kind: string): Promise<Response> {
70
+ let target = findClient(url.searchParams.get("client"))
71
+ if ("error" in target) return target.error
72
+ let id = nextQueryId++
73
+ let reply = new Promise<any>((resolve) => {
74
+ pendingQueries.set(id, resolve)
75
+ })
76
+ target.ws.send(JSON.stringify({ type: "query", kind, id }))
77
+ let msg = await Promise.race([reply, Bun.sleep(QUERY_TIMEOUT_MS)])
78
+ pendingQueries.delete(id)
79
+ if (!msg) return Response.json({ error: "Query timed out" }, { status: 504 })
80
+ if (msg.error) return Response.json({ error: msg.error }, { status: 502 })
81
+ return Response.json(msg.data)
82
+ }
83
+
84
+ // GET /__control__/logs?since=N&wait=MS: entries with seq > since, plus the
85
+ // latest seq as the next cursor. With `wait`, holds the response until a new
86
+ // entry arrives or the timeout passes (long-poll), so a caller can follow the
87
+ // stream without tight polling.
88
+ async function handleLogs(url: URL): Promise<Response> {
89
+ let since = parseInt(url.searchParams.get("since") ?? "0", 10) || 0
90
+ let wait = Math.min(parseInt(url.searchParams.get("wait") ?? "0", 10) || 0, MAX_WAIT_MS)
91
+ let entries = logs.filter((e) => e.seq > since)
92
+ if (entries.length === 0 && wait > 0) {
93
+ await new Promise<void>((resolve) => {
94
+ let timer = setTimeout(resolve, wait)
95
+ logWaiters.push(() => {
96
+ clearTimeout(timer)
97
+ resolve()
98
+ })
99
+ })
100
+ entries = logs.filter((e) => e.seq > since)
101
+ }
102
+ return Response.json({ entries, latest: logSeq })
103
+ }
104
+
105
+ export async function handleControl(req: Request, path: string): Promise<Response> {
106
+ let url = new URL(req.url)
107
+ switch (path) {
108
+ case "/__control__/clients":
109
+ return Response.json(clientList())
110
+ case "/__control__/logs":
111
+ return handleLogs(url)
112
+ case "/__control__/tree":
113
+ return handleQuery(url, "tree")
114
+ case "/__control__/stats":
115
+ return handleQuery(url, "stats")
116
+ default:
117
+ return Response.json({ error: "Unknown control endpoint" }, { status: 404 })
118
+ }
119
+ }
package/src/dev-server.ts CHANGED
@@ -7,6 +7,7 @@ import qrcode from "qrcode-generator"
7
7
  import { state, print } from "./util"
8
8
  import { values } from "./args"
9
9
  import * as cache from "./cache"
10
+ import { appendLog, handleControl, resolveQuery } from "./control"
10
11
 
11
12
  export const DEV_HOST = "127.0.0.1"
12
13
  export const DEV_PORT = 0x8844
@@ -128,6 +129,10 @@ export function startServer() {
128
129
  return handleProxy(req)
129
130
  }
130
131
 
132
+ if (path.startsWith("/__control__/")) {
133
+ return handleControl(req, path)
134
+ }
135
+
131
136
  let filePath = resolve(state.sourceDir, "." + path)
132
137
  if (!filePath.startsWith(state.sourceDir)) {
133
138
  return new Response("Forbidden", { status: 403 })
@@ -169,12 +174,51 @@ export function startServer() {
169
174
  return Response.json(entries, { headers: { "X-SRT-Type": "directory" } })
170
175
  }
171
176
 
172
- return new Response(Bun.file(filePath), { headers: { "X-SRT-Type": "file" } })
177
+ let file = Bun.file(filePath)
178
+ let baseHeaders: Record<string, string> = { "X-SRT-Type": "file", "Accept-Ranges": "bytes" }
179
+
180
+ // Honor a single byte-range request (e.g. streaming audio decoding on the
181
+ // client, which seeks and reads on demand). Only the common "bytes=a-b" /
182
+ // "bytes=a-" / "bytes=-n" forms; anything else falls through to the whole
183
+ // file. Range makes proxied streaming viable without pulling the whole
184
+ // track over the wire.
185
+ let range = req.headers.get("range")
186
+ let match = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null
187
+ if (match) {
188
+ let size = stat.size
189
+ let start: number
190
+ let end: number
191
+ if (match[1] === "") {
192
+ // Suffix range: the last N bytes.
193
+ let n = parseInt(match[2], 10)
194
+ start = isNaN(n) ? 0 : Math.max(0, size - n)
195
+ end = size - 1
196
+ } else {
197
+ start = parseInt(match[1], 10)
198
+ end = match[2] === "" ? size - 1 : Math.min(parseInt(match[2], 10), size - 1)
199
+ }
200
+ if (start > end || start >= size) {
201
+ return new Response("Range not satisfiable", {
202
+ status: 416,
203
+ headers: { ...baseHeaders, "Content-Range": `bytes */${size}` },
204
+ })
205
+ }
206
+ return new Response(file.slice(start, end + 1), {
207
+ status: 206,
208
+ headers: {
209
+ ...baseHeaders,
210
+ "Content-Range": `bytes ${start}-${end}/${size}`,
211
+ "Content-Length": String(end - start + 1),
212
+ },
213
+ })
214
+ }
215
+
216
+ return new Response(file, { headers: baseHeaders })
173
217
  },
174
218
  websocket: {
175
219
  open(ws) {
176
220
  let id = state.nextClientId++
177
- state.clients.set(ws, { platform: "unknown", version: "unknown", id })
221
+ state.clients.set(ws, { platform: "unknown", version: "unknown", id, capabilities: [] })
178
222
  print(`[cli] Client connected ${ws.remoteAddress}`)
179
223
  // Advertise our real LAN address so clients dialed over the adb loopback
180
224
  // can show/remember the directly reachable address (see connection.rs).
@@ -204,8 +248,19 @@ export function startServer() {
204
248
  platform: data.platform ?? "unknown",
205
249
  version: data.version ?? "unknown",
206
250
  id: existing?.id ?? state.nextClientId++,
251
+ capabilities: Array.isArray(data.capabilities) ? data.capabilities.map(String) : [],
207
252
  })
208
253
  print(`[cli] Client info ${ws.remoteAddress} ${data.platform} (${data.version})`)
254
+ } else if (data.type === "log") {
255
+ // Forwarded console output / runtime errors from the client's
256
+ // engine logger, buffered for the control API (see control.ts).
257
+ // Not printed here: the local client already writes to this
258
+ // terminal, so echoing would duplicate every line.
259
+ let device = state.clients.get(ws)?.id ?? -1
260
+ appendLog(device, String(data.level ?? "log"), String(data.text ?? ""))
261
+ } else if (data.type === "result") {
262
+ // Reply to a query the control API forwarded to this client.
263
+ resolveQuery(data)
209
264
  } else if (data.type === "capture" && state.capture) {
210
265
  let device = state.clients.get(ws)?.id ?? -1
211
266
  // Milliseconds, integer: Date.now() is already integer ms, so the
package/src/main.ts CHANGED
@@ -7,6 +7,7 @@ import { runPackCommand } from "./commands/pack"
7
7
  import { runRenderCommand } from "./commands/render"
8
8
  import { runServerCommand } from "./commands/server"
9
9
  import { runClientCommand } from "./commands/client"
10
+ import { runMcpCommand } from "./commands/mcp"
10
11
  import { spawnClient } from "./dev-client"
11
12
 
12
13
  // -- Validate args --
@@ -51,6 +52,8 @@ if (command === "init") {
51
52
  } else if (command === "run") {
52
53
  await runServerCommand()
53
54
  spawnClient()
55
+ } else if (command === "mcp") {
56
+ await runMcpCommand()
54
57
  } else {
55
58
  printUsage()
56
59
  process.exit(1)
package/src/util.ts CHANGED
@@ -6,7 +6,7 @@ import type { Server as BunServer } from "bun"
6
6
  import type { Bonjour } from "bonjour-service"
7
7
 
8
8
  export let state = {
9
- clients: new Map<any, { platform: string; version: string; id: number }>(),
9
+ clients: new Map<any, { platform: string; version: string; id: number; capabilities: string[] }>(),
10
10
  nextClientId: 0,
11
11
  currentCode: null as string | null,
12
12
  source: undefined as string | undefined,