@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.
- package/README.md +67 -5
- package/dist/bridge/api.js +29 -0
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/remote.js +35 -0
- package/dist/bridge/remote.js.map +1 -1
- package/dist/bridge/rpc.js +81 -2
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +60 -3
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +86 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/protocol.js +23 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/tools/discover.js +7 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/input.js +63 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/instances.js +9 -1
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +73 -16
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +8 -1
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/session.js +32 -6
- package/dist/tools/session.js.map +1 -1
- package/dist/tools/world.js +9 -3
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +204 -4
- package/plugin/src/Net.luau +23 -1
- package/plugin/src/Phrase.luau +158 -5
- package/plugin/src/Transport.luau +84 -19
- package/plugin/src/Visuals.luau +217 -20
- package/plugin/src/handlers/Debug.luau +63 -6
- package/plugin/src/handlers/Geometry.luau +20 -1
- package/plugin/src/handlers/Input.luau +41 -1
- package/plugin/src/handlers/Perf.luau +662 -645
- package/plugin/src/handlers/World.luau +19 -0
- package/plugin/src/init.server.luau +48 -11
- package/scripts/check-plugin.mjs +29 -1
- package/scripts/test-bridge.mjs +104 -0
- package/scripts/test-transport.mjs +58 -0
package/plugin/src/Phrase.luau
CHANGED
|
@@ -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
|
|
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",
|
|
147
|
-
else "Create " ..
|
|
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
|
-
|
|
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
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
89
|
-
local
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
222
|
-
|
|
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
|
|