@el4cteo/rbx-studio-mcp 0.1.6 → 0.2.7

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 (43) hide show
  1. package/README.md +67 -5
  2. package/dist/bridge/api.js +29 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/remote.js +35 -0
  5. package/dist/bridge/remote.js.map +1 -1
  6. package/dist/bridge/rpc.js +81 -2
  7. package/dist/bridge/rpc.js.map +1 -1
  8. package/dist/bridge/server.js +60 -3
  9. package/dist/bridge/server.js.map +1 -1
  10. package/dist/index.js +86 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/protocol.js +23 -0
  13. package/dist/lib/protocol.js.map +1 -1
  14. package/dist/tools/discover.js +7 -1
  15. package/dist/tools/discover.js.map +1 -1
  16. package/dist/tools/input.js +63 -1
  17. package/dist/tools/input.js.map +1 -1
  18. package/dist/tools/instances.js +9 -1
  19. package/dist/tools/instances.js.map +1 -1
  20. package/dist/tools/perf.js +73 -16
  21. package/dist/tools/perf.js.map +1 -1
  22. package/dist/tools/playtest.js +8 -1
  23. package/dist/tools/playtest.js.map +1 -1
  24. package/dist/tools/session.js +32 -6
  25. package/dist/tools/session.js.map +1 -1
  26. package/dist/tools/world.js +9 -3
  27. package/dist/tools/world.js.map +1 -1
  28. package/package.json +2 -2
  29. package/plugin/src/Config.luau +1 -1
  30. package/plugin/src/Console.luau +204 -4
  31. package/plugin/src/Net.luau +23 -1
  32. package/plugin/src/Phrase.luau +158 -5
  33. package/plugin/src/Transport.luau +84 -19
  34. package/plugin/src/Visuals.luau +217 -20
  35. package/plugin/src/handlers/Debug.luau +63 -6
  36. package/plugin/src/handlers/Geometry.luau +20 -1
  37. package/plugin/src/handlers/Input.luau +41 -1
  38. package/plugin/src/handlers/Perf.luau +662 -645
  39. package/plugin/src/handlers/World.luau +19 -0
  40. package/plugin/src/init.server.luau +48 -11
  41. package/scripts/check-plugin.mjs +29 -1
  42. package/scripts/test-bridge.mjs +104 -0
  43. package/scripts/test-transport.mjs +58 -0
@@ -135,16 +135,29 @@ local DESCRIBERS: { [string]: Describer } = {
135
135
  return string.format('Search scripts for "%s"', pattern)
136
136
  end,
137
137
 
138
+ --[[
139
+ Names the class, not just whatever the caller happened to call the
140
+ thing. "Create MainMenu in StarterGui" was true and useless -- a name is
141
+ whatever someone typed, and it says nothing about what actually landed
142
+ in the place. `className` is required by the tool itself, so it is
143
+ always there; the chosen name rides along in quotes when it was given,
144
+ the way script.grep already quotes a search term.
145
+ ]]
138
146
  ["instances.create"] = function(params)
139
147
  local instances = params.instances
140
148
  local total = count(instances)
141
149
  if total == 1 then
142
150
  local one = (instances :: { any })[1]
143
- local name = one.name or one.className or "instance"
151
+ local className = if typeof(one.className) == "string" and one.className ~= ""
152
+ then one.className
153
+ else "instance"
154
+ local subject = if typeof(one.name) == "string" and one.name ~= ""
155
+ then string.format('%s "%s"', className, one.name)
156
+ else className
144
157
  local where = leaf(one.parent)
145
158
  return if where ~= nil
146
- then string.format("Create %s in %s", name, where)
147
- else "Create " .. name
159
+ then string.format("Create %s in %s", subject, where)
160
+ else "Create " .. subject
148
161
  end
149
162
  local where = if total > 0 then leaf((instances :: { any })[1].parent) else nil
150
163
  return if where ~= nil
@@ -157,16 +170,45 @@ local DESCRIBERS: { [string]: Describer } = {
157
170
  -- total across them rather than the number of entries.
158
171
  local total = 0
159
172
  local onlyName: string? = nil
173
+ --[[
174
+ Which properties/attributes actually changed, not just how many
175
+ instances did. "Modify Sword" said something happened and nothing
176
+ about what; a caller watching for "did the color change" had no way
177
+ to tell this apart from a rename or an attribute flip.
178
+ ]]
179
+ local fields: { string } = {}
180
+ local seenFields: { [string]: boolean } = {}
181
+ local function noteFields(bag: any)
182
+ if typeof(bag) ~= "table" then
183
+ return
184
+ end
185
+ for key in bag :: { [string]: any } do
186
+ if not seenFields[key] then
187
+ seenFields[key] = true
188
+ table.insert(fields, key)
189
+ end
190
+ end
191
+ end
160
192
  for _, target in (targets or {}) :: { { [string]: any } } do
161
193
  local paths = count(target.paths)
162
194
  total += paths
163
195
  if paths >= 1 and onlyName == nil then
164
196
  onlyName = leaf((target.paths :: { any })[1])
165
197
  end
198
+ noteFields(target.properties)
199
+ noteFields(target.attributes)
166
200
  end
167
201
  if total == 1 and onlyName ~= nil then
202
+ if #fields == 1 then
203
+ return string.format("Set %s on %s", fields[1], onlyName)
204
+ elseif #fields > 1 then
205
+ return string.format("Modify %s (%s)", onlyName, table.concat(fields, ", "))
206
+ end
168
207
  return "Modify " .. onlyName
169
208
  end
209
+ if #fields == 1 then
210
+ return string.format("Set %s on %s", fields[1], plural(total, "instance"))
211
+ end
170
212
  return "Modify " .. plural(total, "instance")
171
213
  end,
172
214
  ["instances.delete"] = function(params)
@@ -181,11 +223,18 @@ local DESCRIBERS: { [string]: Describer } = {
181
223
  local one = (items :: { any })[1]
182
224
  local what = leaf(one.path)
183
225
  local where = leaf(one.to)
226
+ -- A rename in the same call changes what actually ends up in the
227
+ -- place; "Clone Sword to Backpack" while it lands as "GoldSword"
228
+ -- described an instance that was never created under that name.
229
+ local rename = if typeof(one.name) == "string" and one.name ~= "" then one.name else nil
184
230
  if what ~= nil and where ~= nil then
185
- return string.format("%s %s to %s", verb, what, where)
231
+ local base = string.format("%s %s to %s", verb, what, where)
232
+ return if rename ~= nil then base .. " as " .. rename else base
186
233
  end
187
234
  if what ~= nil then
188
- return verb .. " " .. what
235
+ return if rename ~= nil
236
+ then string.format("%s %s as %s", verb, what, rename)
237
+ else verb .. " " .. what
189
238
  end
190
239
  end
191
240
  return string.format("%s %s", verb, plural(total, "instance"))
@@ -196,6 +245,18 @@ local DESCRIBERS: { [string]: Describer } = {
196
245
  return if where ~= nil then "Browse " .. where else "Browse the place"
197
246
  end,
198
247
  ["discover.inspect"] = function(params)
248
+ --[[
249
+ `inspect` (no explicit `properties`, not concise detail) and `modify`
250
+ (when setting properties) both probe a class before doing the thing
251
+ the caller actually asked for -- reading the properties that matter
252
+ for it, or typing the values it is about to write. That probe is a
253
+ second, genuine call to this same op, and unmarked it logged
254
+ identically to the read it precedes: two lines reading "Inspect X"
255
+ back to back, indistinguishable from asking twice by mistake.
256
+ ]]
257
+ if params.probeOnly == true then
258
+ return "Check the class of " .. subjectOf(params.paths, "instance")
259
+ end
199
260
  return "Inspect " .. subjectOf(params.paths, "instance")
200
261
  end,
201
262
  ["discover.find"] = function(params)
@@ -262,6 +323,18 @@ local DESCRIBERS: { [string]: Describer } = {
262
323
 
263
324
  ["playtest.control"] = function(params)
264
325
  local op = tostring(params.op or "state")
326
+ --[[
327
+ `stop` ends a test by polling this from the *surviving* session every
328
+ 400ms until the teardown settles, sometimes five or six times in two
329
+ seconds. Labelled "Check the playtest" like an agent's own status
330
+ check, that read as unexplained repetition with no `stop` line anywhere
331
+ to explain it -- the actual stop went to the session that is by then
332
+ gone, and its console went with it. This name is why: waiting for the
333
+ teardown it just started, not asking again for no reason.
334
+ ]]
335
+ if op == "state" and params.waitingForStop == true then
336
+ return "Wait for the playtest to stop"
337
+ end
265
338
  if op == "play" then
266
339
  return "Start a playtest"
267
340
  elseif op == "run" then
@@ -331,6 +404,12 @@ local DESCRIBERS: { [string]: Describer } = {
331
404
  ["capture.screenshot"] = function()
332
405
  return "Take a screenshot"
333
406
  end,
407
+ ["capture.playtestId"] = function()
408
+ return "Capture the playtest view"
409
+ end,
410
+ ["capture.decode"] = function()
411
+ return "Read back the capture"
412
+ end,
334
413
 
335
414
  ["viewport.focus"] = function(params)
336
415
  local name = leaf(params.path)
@@ -378,6 +457,8 @@ local DESCRIBERS: { [string]: Describer } = {
378
457
  return "Create collision group " .. group
379
458
  elseif action == "assign" then
380
459
  return string.format("Put %s in %s", plural(count(params.paths), "instance"), group)
460
+ elseif action == "remove" then
461
+ return "Remove collision group " .. group
381
462
  end
382
463
  return string.format("Set %s against %s", group, tostring(params.with or "?"))
383
464
  end,
@@ -400,6 +481,73 @@ local DESCRIBERS: { [string]: Describer } = {
400
481
  ["studio.ping"] = function()
401
482
  return "Ping"
402
483
  end,
484
+ ["studio.transport"] = function(params)
485
+ local mode = params.mode
486
+ return if typeof(mode) == "string" and mode ~= ""
487
+ then string.format("Switch to %s transport", mode)
488
+ else "Check the transport"
489
+ end,
490
+
491
+ --[[
492
+ The one a person actually watches: a live playtest is the point where
493
+ "what is the AI doing" stops being abstract, and a console line reading
494
+ "Input send" says nothing a bystander could act on. One step names the
495
+ key or point; several collapse to a count, same as every other batch op.
496
+ ]]
497
+ ["input.send"] = function(params)
498
+ local steps = params.steps
499
+ local total = count(steps)
500
+ if total == 1 then
501
+ local step = (steps :: { any })[1]
502
+ local kind = tostring(step.kind or "key")
503
+ if kind == "key" then
504
+ local action = tostring(step.action or "tap")
505
+ local verb = if action == "press" then "Hold" elseif action == "release" then "Release" else "Press"
506
+ return string.format("%s %s", verb, tostring(step.key or "?"))
507
+ elseif kind == "click" then
508
+ return string.format("Click (%s, %s)", tostring(step.x or "?"), tostring(step.y or "?"))
509
+ elseif kind == "move" then
510
+ return "Move the pointer"
511
+ elseif kind == "text" then
512
+ return string.format('Type "%s"', tostring(step.text or ""))
513
+ end
514
+ end
515
+ return "Send " .. plural(total, "input step")
516
+ end,
517
+
518
+ ["api.describe"] = function(params)
519
+ local className = params.className
520
+ return if typeof(className) == "string" and className ~= ""
521
+ then "Look up " .. className
522
+ else "Look up a class"
523
+ end,
524
+ ["api.classes"] = function(params)
525
+ local needle = params.contains
526
+ return if typeof(needle) == "string" and needle ~= ""
527
+ then string.format('Search classes for "%s"', needle)
528
+ else "List classes"
529
+ end,
530
+
531
+ ["perf.scene"] = function(params)
532
+ local section = params.section
533
+ return if typeof(section) == "string" and section ~= ""
534
+ then "Measure the " .. section
535
+ else "Measure the scene"
536
+ end,
537
+
538
+ ["device.list"] = function()
539
+ return "List devices"
540
+ end,
541
+ ["device.set"] = function(params)
542
+ local id = params.device
543
+ return if typeof(id) == "string" and id ~= "" then "Emulate " .. id else "Emulate a device"
544
+ end,
545
+ ["device.stop"] = function()
546
+ return "Stop emulating a device"
547
+ end,
548
+ ["device.state"] = function()
549
+ return "Check the emulated device"
550
+ end,
403
551
  }
404
552
 
405
553
  --[[
@@ -421,8 +569,13 @@ local KINDS: { [string]: string } = {
421
569
  exec = "run",
422
570
  playtest = "run",
423
571
  character = "run",
572
+ -- Live keys and clicks into a running playtest are an action, not a read,
573
+ -- and belong beside character/exec rather than defaulting to "read".
574
+ input = "run",
424
575
  debug = "debug",
425
576
  perf = "debug",
577
+ api = "read",
578
+ device = "read",
426
579
  }
427
580
 
428
581
  function Phrase.kindOf(op: string): string
@@ -28,6 +28,19 @@ export type Status = "disconnected" | "connecting" | "connected"
28
28
  type Callbacks = {
29
29
  onCommand: (id: string, op: string, params: { [string]: any }?) -> (),
30
30
  onStatus: (status: Status, detail: string?) -> (),
31
+ --[[
32
+ Anything the bridge wants to tell this plugin that is not a command.
33
+
34
+ The stream was one-way in practice -- commands down, results back over a
35
+ separate POST -- and there was no way for the server to say something the
36
+ plugin should merely know, like how many agents are now sharing it. A
37
+ frame carrying `event` instead of `id`/`op` is that channel.
38
+
39
+ No protocol bump: a plugin older than this drops any frame without an id
40
+ and an op, which is exactly the right behaviour for a frame it does not
41
+ understand.
42
+ ]]
43
+ onEvent: (event: { [string]: any }) -> (),
31
44
  }
32
45
 
33
46
  local running = false
@@ -71,6 +84,12 @@ local function identity(transport: string): { [string]: any }
71
84
  placeId = game.PlaceId,
72
85
  pluginVersion = Config.PLUGIN_VERSION,
73
86
  buildId = Config.BUILD_ID,
87
+ -- Alongside buildId rather than instead of it: buildId catches a plugin
88
+ -- left running an older *behaviour*, this catches the wire *shape*
89
+ -- changing under it. Compared server-side the same way buildId is, so a
90
+ -- mismatch surfaces through studio_status instead of failing silently
91
+ -- the next time PROTOCOL_VERSION actually moves.
92
+ protocolVersion = Config.PROTOCOL_VERSION,
74
93
  transport = transport,
75
94
  -- Announced at the handshake rather than left for a later status call.
76
95
  -- The ambiguity error that forces someone to choose between two rows
@@ -81,23 +100,53 @@ local function identity(transport: string): { [string]: any }
81
100
  end
82
101
 
83
102
  --[[
84
- Parses one SSE payload. `MessageReceived` hands over the event data, but the
85
- exact framing is not contractual, so a stray `data:` prefix is tolerated and
86
- comment/keepalive lines are dropped.
103
+ Parses one delivery from the stream, which may hold more than one event.
104
+
105
+ This used to assume one event per `MessageReceived` and decode the whole
106
+ payload as a single JSON object. That held only because the bridge had never
107
+ written two frames in the same tick -- and the moment it did, both were lost:
108
+ the two writes arrived in one chunk, so the decode saw
109
+ `{...}<blank>data: {...}` and returned nil for the pair. The visible symptom
110
+ was a client-count badge that went up and never came back down. The invisible
111
+ one is why this is written properly rather than patched around: nothing stops
112
+ a *command* from sharing a chunk with anything else, and a dropped command is
113
+ a tool call that disappears with no error on either side.
114
+
115
+ So the payload is split into events on blank lines, and each event's `data:`
116
+ lines are concatenated the way the SSE spec says they should be. Comments --
117
+ the `: connected` greeting and the `: ping` keepalives -- are skipped rather
118
+ than being allowed to poison the block they arrive with.
87
119
  ]]
88
- local function parseFrame(message: string): { [string]: any }?
89
- local text = (string.gsub(message, "^%s+", ""))
90
- if text == "" or string.sub(text, 1, 1) == ":" then
91
- return nil
92
- end
93
- if string.sub(text, 1, 5) == "data:" then
94
- text = (string.gsub(string.sub(text, 6), "^%s+", ""))
95
- end
96
- local decoded = Net.decode(text)
97
- if typeof(decoded) ~= "table" then
98
- return nil
120
+ local function parseFrames(message: string): { { [string]: any } }
121
+ local frames: { { [string]: any } } = {}
122
+
123
+ -- Normalised so one split handles either line ending.
124
+ local normalised = (string.gsub(message, "\r\n", "\n"))
125
+
126
+ -- The appended separator gives every event, the last one included, a
127
+ -- trailing blank line, so a single pattern finds them all.
128
+ for block in string.gmatch(normalised .. "\n\n", "(.-)\n\n") do
129
+ local payload: string? = nil
130
+ for line in string.gmatch(block, "[^\n]+") do
131
+ if string.sub(line, 1, 1) ~= ":" then
132
+ local value = line
133
+ if string.sub(value, 1, 5) == "data:" then
134
+ value = (string.gsub(string.sub(value, 6), "^%s+", ""))
135
+ end
136
+ -- Several data lines in one event concatenate, per the spec.
137
+ payload = if payload == nil then value else payload .. "\n" .. value
138
+ end
139
+ end
140
+
141
+ if payload ~= nil and payload ~= "" then
142
+ local decoded = Net.decode(payload)
143
+ if typeof(decoded) == "table" then
144
+ table.insert(frames, decoded)
145
+ end
146
+ end
99
147
  end
100
- return decoded
148
+
149
+ return frames
101
150
  end
102
151
 
103
152
  local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
@@ -105,6 +154,10 @@ local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
105
154
  local op = frame.op
106
155
  if typeof(id) == "string" and typeof(op) == "string" then
107
156
  callbacks.onCommand(id, op, frame.params)
157
+ return
158
+ end
159
+ if typeof(frame.event) == "string" then
160
+ callbacks.onEvent(frame)
108
161
  end
109
162
  end
110
163
 
@@ -145,8 +198,7 @@ local function runStream(callbacks: Callbacks): boolean
145
198
  table.insert(
146
199
  connections,
147
200
  client.MessageReceived:Connect(function(message: string)
148
- local frame = parseFrame(message)
149
- if frame then
201
+ for _, frame in parseFrames(message) do
150
202
  handleFrame(callbacks, frame)
151
203
  end
152
204
  end)
@@ -218,8 +270,21 @@ local function runPolling(callbacks: Callbacks): boolean
218
270
  return false
219
271
  else
220
272
  local payload = Net.decode(response.body)
221
- if typeof(payload) == "table" and typeof(payload.command) == "table" then
222
- handleFrame(callbacks, payload.command)
273
+ if typeof(payload) == "table" then
274
+ if typeof(payload.command) == "table" then
275
+ handleFrame(callbacks, payload.command)
276
+ end
277
+ --[[
278
+ Poll sessions get the same news, riding on the answer they were
279
+ already waiting for. The bridge has nowhere to push to here, so
280
+ every poll response carries the current value rather than only
281
+ the moments it changed -- the console filters out the repeats,
282
+ and a poll session that missed a change while it was handling a
283
+ command would otherwise never hear about it.
284
+ ]]
285
+ if typeof(payload.clients) == "number" then
286
+ callbacks.onEvent({ event = "clients", count = payload.clients })
287
+ end
223
288
  end
224
289
  end
225
290