@el4cteo/rbx-studio-mcp 0.7.2 → 0.7.5
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/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +11 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +7 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +11 -1
- package/dist/tools/playtest.js.map +1 -1
- package/package.json +75 -75
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-live.mjs +283 -115
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +151 -39
|
@@ -12,99 +12,16 @@
|
|
|
12
12
|
the reason for running it at all.
|
|
13
13
|
]]
|
|
14
14
|
|
|
15
|
-
local LogService = game:GetService("LogService")
|
|
16
15
|
local RunService = game:GetService("RunService")
|
|
17
16
|
|
|
18
17
|
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
19
|
-
local
|
|
20
|
-
|
|
21
|
-
-- A chunk that prints in a loop would otherwise return a response no context
|
|
22
|
-
-- window can hold.
|
|
23
|
-
local MAX_OUTPUT_LINES = 200
|
|
24
|
-
local MAX_RETURN_VALUES = 10
|
|
25
|
-
|
|
26
|
-
-- Returned tables are walked rather than counted, but not without limit: a
|
|
27
|
-
-- chunk that returns a whole config tree, or one with a cycle in it, must not be
|
|
28
|
-
-- able to produce a response nothing can read.
|
|
29
|
-
local MAX_TABLE_ENTRIES = 50
|
|
30
|
-
local MAX_TABLE_DEPTH = 4
|
|
18
|
+
local Runtime = require(script.Parent.Parent.ExecRuntime)
|
|
19
|
+
local ClientRelay = require(script.Parent.Parent.ClientRelay)
|
|
31
20
|
|
|
32
21
|
local CHUNK_NAME = "StudioMCP.execute_luau"
|
|
33
22
|
|
|
34
23
|
local Exec = {}
|
|
35
24
|
|
|
36
|
-
--[[
|
|
37
|
-
Describes a returned value.
|
|
38
|
-
|
|
39
|
-
Tables used to come back as "<table with 5 entries>", which threw away the
|
|
40
|
-
answer whenever the answer was a table -- and returning a table is the natural
|
|
41
|
-
way to report several things at once, so this hit constantly. They are walked
|
|
42
|
-
now, bounded by breadth and depth. Everything else goes through the same
|
|
43
|
-
serializer the rest of the server uses, so a Vector3 reads the way it does
|
|
44
|
-
everywhere else.
|
|
45
|
-
]]
|
|
46
|
-
local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
|
|
47
|
-
local level = depth or 0
|
|
48
|
-
local visited = seen or {}
|
|
49
|
-
local kind = typeof(value)
|
|
50
|
-
|
|
51
|
-
if kind == "function" or kind == "thread" then
|
|
52
|
-
return string.format("<%s>", kind)
|
|
53
|
-
end
|
|
54
|
-
if kind ~= "table" then
|
|
55
|
-
return Serialize.value(value)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
local source = value :: { [any]: any }
|
|
59
|
-
-- Named separately from the depth cap, because they mean opposite things to
|
|
60
|
-
-- a reader. The depth cap says "there is more below this"; a cycle says
|
|
61
|
-
-- "this is the table you are already inside". Reporting the second as the
|
|
62
|
-
-- first printed `t.self.self.self` as three distinct nested tables, which
|
|
63
|
-
-- reads as real structure that does not exist.
|
|
64
|
-
if visited[source] then
|
|
65
|
-
return "<circular reference>"
|
|
66
|
-
end
|
|
67
|
-
if level >= MAX_TABLE_DEPTH then
|
|
68
|
-
return "<nested table>"
|
|
69
|
-
end
|
|
70
|
-
visited[source] = true
|
|
71
|
-
|
|
72
|
-
local count = 0
|
|
73
|
-
for _ in source do
|
|
74
|
-
count += 1
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
-- Arrays keep their shape so they read as lists on the other side; anything
|
|
78
|
-
-- with non-sequential keys becomes a map with stringified keys.
|
|
79
|
-
if count == #source then
|
|
80
|
-
local list: { any } = {}
|
|
81
|
-
for index, item in ipairs(source) do
|
|
82
|
-
if index > MAX_TABLE_ENTRIES then
|
|
83
|
-
table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
|
|
84
|
-
break
|
|
85
|
-
end
|
|
86
|
-
table.insert(list, describe(item, level + 1, visited))
|
|
87
|
-
end
|
|
88
|
-
-- Cleared on the way out so the same table appearing twice side by side
|
|
89
|
-
-- still renders twice; only a table containing itself is a cycle.
|
|
90
|
-
visited[source] = nil
|
|
91
|
-
return list
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
local map: { [string]: any } = {}
|
|
95
|
-
local shown = 0
|
|
96
|
-
for key, item in source do
|
|
97
|
-
shown += 1
|
|
98
|
-
if shown > MAX_TABLE_ENTRIES then
|
|
99
|
-
map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
|
|
100
|
-
break
|
|
101
|
-
end
|
|
102
|
-
map[tostring(key)] = describe(item, level + 1, visited)
|
|
103
|
-
end
|
|
104
|
-
visited[source] = nil
|
|
105
|
-
return map
|
|
106
|
-
end
|
|
107
|
-
|
|
108
25
|
--[[
|
|
109
26
|
Compiles the chunk by whichever route this session actually allows.
|
|
110
27
|
|
|
@@ -225,6 +142,36 @@ function Exec.run(params: { [string]: any }): { [string]: any }
|
|
|
225
142
|
)
|
|
226
143
|
end
|
|
227
144
|
|
|
145
|
+
if params.target == "client" then
|
|
146
|
+
local player = ClientRelay.playerFor(params.player)
|
|
147
|
+
local timeout = math.clamp(tonumber(params.timeoutSeconds) or 30, 1, 300)
|
|
148
|
+
local result = ClientRelay.run(player, "MCPExecRelay", [==[
|
|
149
|
+
local report = script:WaitForChild("Report", 10)
|
|
150
|
+
if not report then return end
|
|
151
|
+
local ok, result = pcall(function()
|
|
152
|
+
assert(game:GetService("RunService"):IsClient(), "Client VM required")
|
|
153
|
+
local runtime = require(script:WaitForChild("ExecRuntime"))
|
|
154
|
+
local compiled, chunk = pcall(require, script:WaitForChild("Chunk"))
|
|
155
|
+
if not compiled then
|
|
156
|
+
return { ok = false, error = tostring(chunk), output = {}, milliseconds = 0 }
|
|
157
|
+
end
|
|
158
|
+
return runtime.run(chunk, script:GetAttribute("Timeout"))
|
|
159
|
+
end)
|
|
160
|
+
report:FireServer(if ok then result else { ok = false, error = tostring(result), output = {}, milliseconds = 0 })
|
|
161
|
+
]==], { Timeout = timeout }, timeout + 5, function(relay)
|
|
162
|
+
-- These modules are required ONLY by the LocalScript, sharing the game's client VM.
|
|
163
|
+
for _, name in { "ExecRuntime", "Serialize", "Paths", "Dispatch" } do
|
|
164
|
+
local copy = script.Parent.Parent[name]:Clone()
|
|
165
|
+
copy.Parent = relay
|
|
166
|
+
end
|
|
167
|
+
local chunk = Instance.new("ModuleScript")
|
|
168
|
+
chunk.Name = "Chunk"
|
|
169
|
+
chunk.Parent = relay
|
|
170
|
+
chunk.Source = "return function(...) " .. source .. "\nend"
|
|
171
|
+
end)
|
|
172
|
+
return result
|
|
173
|
+
end
|
|
174
|
+
|
|
228
175
|
local chunk, compileError, route, module = compile(source)
|
|
229
176
|
if chunk == nil then
|
|
230
177
|
if route == "none" then
|
|
@@ -243,82 +190,10 @@ function Exec.run(params: { [string]: any }): { [string]: any }
|
|
|
243
190
|
)
|
|
244
191
|
end
|
|
245
192
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
local connection = LogService.MessageOut:Connect(function(message, messageType)
|
|
251
|
-
if #output < MAX_OUTPUT_LINES then
|
|
252
|
-
table.insert(output, {
|
|
253
|
-
message = message,
|
|
254
|
-
level = if messageType == Enum.MessageType.MessageError
|
|
255
|
-
then "error"
|
|
256
|
-
elseif messageType == Enum.MessageType.MessageWarning then "warning"
|
|
257
|
-
else "print",
|
|
258
|
-
})
|
|
259
|
-
end
|
|
260
|
-
end)
|
|
261
|
-
|
|
262
|
-
local started = os.clock()
|
|
263
|
-
local results = table.pack(pcall(chunk :: () -> ...any))
|
|
264
|
-
local elapsed = os.clock() - started
|
|
265
|
-
|
|
266
|
-
--[[
|
|
267
|
-
MessageOut is deferred: `print` returns before the event fires, so
|
|
268
|
-
disconnecting straight after the chunk finished captured nothing at all.
|
|
269
|
-
A short settle picks the lines up.
|
|
270
|
-
|
|
271
|
-
It is bounded and stops as soon as the flow stops, rather than waiting a
|
|
272
|
-
fixed period, so a chunk that logged nothing costs almost no time and one
|
|
273
|
-
that logged plenty is not truncated.
|
|
274
|
-
]]
|
|
275
|
-
local settleDeadline = os.clock() + 0.5
|
|
276
|
-
local seen = #output
|
|
277
|
-
local quietFrames = 0
|
|
278
|
-
while os.clock() < settleDeadline and quietFrames < 3 do
|
|
279
|
-
task.wait()
|
|
280
|
-
if #output == seen then
|
|
281
|
-
quietFrames += 1
|
|
282
|
-
else
|
|
283
|
-
seen = #output
|
|
284
|
-
quietFrames = 0
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
connection:Disconnect()
|
|
289
|
-
|
|
290
|
-
if module then
|
|
291
|
-
module:Destroy()
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
local identity = notes(route, source)
|
|
295
|
-
|
|
296
|
-
if not results[1] then
|
|
297
|
-
return {
|
|
298
|
-
ok = false,
|
|
299
|
-
error = tostring(results[2]),
|
|
300
|
-
output = output,
|
|
301
|
-
note = identity,
|
|
302
|
-
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
303
|
-
}
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
local returned: { any } = {}
|
|
307
|
-
for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
|
|
308
|
-
-- A nil return is written out rather than dropped. `table.insert` of nil
|
|
309
|
-
-- does nothing, so `return a, nil, b` came back as two values and every
|
|
310
|
-
-- one after the nil shifted into the wrong position.
|
|
311
|
-
local value = results[index]
|
|
312
|
-
table.insert(returned, if value == nil then "nil" else describe(value))
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
ok = true,
|
|
317
|
-
returned = returned,
|
|
318
|
-
output = output,
|
|
319
|
-
note = identity,
|
|
320
|
-
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
321
|
-
}
|
|
193
|
+
local result = Runtime.run(chunk :: any)
|
|
194
|
+
if module then module:Destroy() end
|
|
195
|
+
result.note = notes(route, source)
|
|
196
|
+
return result
|
|
322
197
|
end
|
|
323
198
|
|
|
324
199
|
function Exec.register()
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
]]
|
|
25
25
|
|
|
26
26
|
local HttpService = game:GetService("HttpService")
|
|
27
|
-
local
|
|
28
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
27
|
+
local ClientRelay = require(script.Parent.Parent.ClientRelay)
|
|
29
28
|
local RunService = game:GetService("RunService")
|
|
30
29
|
|
|
31
30
|
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
@@ -111,7 +110,7 @@ local performed = {}
|
|
|
111
110
|
local landed = nil
|
|
112
111
|
local aimed = nil
|
|
113
112
|
local seenAt = nil
|
|
114
|
-
UserInputService.InputBegan:Connect(function(input)
|
|
113
|
+
local inputConnection = UserInputService.InputBegan:Connect(function(input)
|
|
115
114
|
local kind = input.UserInputType
|
|
116
115
|
if aimed ~= nil and (kind == Enum.UserInputType.MouseButton1 or kind == Enum.UserInputType.Touch) then
|
|
117
116
|
seenAt = { x = input.Position.X, y = input.Position.Y }
|
|
@@ -192,6 +191,16 @@ local HOTSPOT_FALLBACK = Vector2.new(0.484, 0.5)
|
|
|
192
191
|
|
|
193
192
|
local cursor = nil
|
|
194
193
|
local cursorGui = nil
|
|
194
|
+
local function cleanup()
|
|
195
|
+
inputConnection:Disconnect()
|
|
196
|
+
if cursorGui then cursorGui:Destroy() end
|
|
197
|
+
end
|
|
198
|
+
relay.Destroying:Connect(cleanup)
|
|
199
|
+
local mainThread = coroutine.running()
|
|
200
|
+
local watchdog = task.delay(relay:GetAttribute("Budget") or 20, function()
|
|
201
|
+
cleanup()
|
|
202
|
+
task.cancel(mainThread)
|
|
203
|
+
end)
|
|
195
204
|
|
|
196
205
|
local function makeCursor()
|
|
197
206
|
if relay:GetAttribute("Cursor") ~= true then
|
|
@@ -322,7 +331,29 @@ local function refusedKey(key, err)
|
|
|
322
331
|
return string.format("%s was not sent: %s", key, message)
|
|
323
332
|
end
|
|
324
333
|
|
|
334
|
+
local function guiTarget(path)
|
|
335
|
+
local object = Players.LocalPlayer
|
|
336
|
+
for segment in string.gmatch(path, "[^%.]+") do
|
|
337
|
+
object = object:FindFirstChild(segment)
|
|
338
|
+
if object == nil then error("GUI target not found: " .. path) end
|
|
339
|
+
end
|
|
340
|
+
if not object:IsA("GuiObject") then error("Target is not a GuiObject: " .. path) end
|
|
341
|
+
return object
|
|
342
|
+
end
|
|
343
|
+
|
|
325
344
|
local function runStep(step): boolean
|
|
345
|
+
local target = if step.target then guiTarget(step.target) else nil
|
|
346
|
+
if target then
|
|
347
|
+
local centre = target.AbsolutePosition + target.AbsoluteSize / 2
|
|
348
|
+
local inset = game:GetService("GuiService"):GetGuiInset()
|
|
349
|
+
step.x, step.y = centre.X + inset.X, centre.Y + inset.Y
|
|
350
|
+
if target:IsA("TextBox") then
|
|
351
|
+
target:CaptureFocus()
|
|
352
|
+
task.wait(0.05)
|
|
353
|
+
elseif step.kind == "text" then
|
|
354
|
+
error("Text target must be a TextBox: " .. step.target)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
326
357
|
local kind = step.kind
|
|
327
358
|
if kind == "key" then
|
|
328
359
|
local code = Enum.KeyCode[step.key]
|
|
@@ -410,6 +441,8 @@ if cursorGui ~= nil then
|
|
|
410
441
|
cursorGui:Destroy()
|
|
411
442
|
end
|
|
412
443
|
|
|
444
|
+
task.cancel(watchdog)
|
|
445
|
+
inputConnection:Disconnect()
|
|
413
446
|
report:FireServer({
|
|
414
447
|
ok = true,
|
|
415
448
|
performed = performed,
|
|
@@ -420,35 +453,6 @@ report:FireServer({
|
|
|
420
453
|
})
|
|
421
454
|
]==]
|
|
422
455
|
|
|
423
|
-
local function playerFor(name: string?): Player
|
|
424
|
-
local players = Players:GetPlayers()
|
|
425
|
-
if #players == 0 then
|
|
426
|
-
Dispatch.fail(
|
|
427
|
-
"NO_PLAYER",
|
|
428
|
-
"No player is in this session.",
|
|
429
|
-
"Input needs a running playtest with a character. Use `playtest op=\"play\"`, "
|
|
430
|
-
.. "then address this at the playtest's studioId."
|
|
431
|
-
)
|
|
432
|
-
end
|
|
433
|
-
if name == nil or name == "" then
|
|
434
|
-
if #players > 1 then
|
|
435
|
-
Dispatch.fail(
|
|
436
|
-
"AMBIGUOUS_PLAYER",
|
|
437
|
-
string.format("%d players are in this session.", #players),
|
|
438
|
-
"Name one with `player`."
|
|
439
|
-
)
|
|
440
|
-
end
|
|
441
|
-
return players[1]
|
|
442
|
-
end
|
|
443
|
-
for _, player in players do
|
|
444
|
-
if player.Name == name then
|
|
445
|
-
return player
|
|
446
|
-
end
|
|
447
|
-
end
|
|
448
|
-
Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this session.", name))
|
|
449
|
-
return players[1]
|
|
450
|
-
end
|
|
451
|
-
|
|
452
456
|
--[[
|
|
453
457
|
Turns the request into steps the relay understands, validating names here
|
|
454
458
|
rather than on the client where a bad one would silently do nothing.
|
|
@@ -469,6 +473,12 @@ local function planFrom(params: { [string]: any }): { { [string]: any } }
|
|
|
469
473
|
for index, raw in steps :: { { [string]: any } } do
|
|
470
474
|
local kind = tostring(raw.kind or "key")
|
|
471
475
|
local step: { [string]: any } = { kind = kind }
|
|
476
|
+
if raw.target ~= nil then
|
|
477
|
+
if (kind ~= "click" and kind ~= "text") or typeof(raw.target) ~= "string" or raw.target == "" then
|
|
478
|
+
Dispatch.fail("BAD_PARAMS", "target must be a GUI path on a click or text step.")
|
|
479
|
+
end
|
|
480
|
+
step.target = raw.target
|
|
481
|
+
end
|
|
472
482
|
|
|
473
483
|
if raw.after ~= nil then
|
|
474
484
|
step.after = math.clamp(tonumber(raw.after) or 0, 0, MAX_HOLD)
|
|
@@ -539,52 +549,21 @@ function Input.send(params: { [string]: any }): { [string]: any }
|
|
|
539
549
|
end
|
|
540
550
|
|
|
541
551
|
local plan = planFrom(params)
|
|
542
|
-
local player = playerFor(
|
|
543
|
-
local playerGui = player:FindFirstChildOfClass("PlayerGui")
|
|
544
|
-
if playerGui == nil then
|
|
545
|
-
Dispatch.fail("NO_PLAYER", string.format("%s has no PlayerGui yet.", player.Name))
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
local remote = Instance.new("RemoteEvent")
|
|
549
|
-
remote.Name = "Report"
|
|
550
|
-
|
|
551
|
-
local relay = Instance.new("LocalScript")
|
|
552
|
-
relay.Name = "MCPInputRelay"
|
|
553
|
-
relay.Source = RELAY_SOURCE
|
|
554
|
-
relay:SetAttribute("Plan", HttpService:JSONEncode(plan))
|
|
555
|
-
relay:SetAttribute("Cursor", params.cursor ~= false)
|
|
556
|
-
|
|
557
|
-
remote.Parent = relay
|
|
558
|
-
|
|
559
|
-
local answer: { [string]: any }? = nil
|
|
560
|
-
local connection = remote.OnServerEvent:Connect(function(_from, payload)
|
|
561
|
-
if typeof(payload) == "table" then
|
|
562
|
-
answer = payload :: { [string]: any }
|
|
563
|
-
end
|
|
564
|
-
end)
|
|
565
|
-
|
|
566
|
-
-- Parenting last: the script starts the moment it lands, and it waits for
|
|
567
|
-
-- the RemoteEvent, so both must already be in place.
|
|
568
|
-
relay.Parent = playerGui
|
|
569
|
-
|
|
570
|
-
local deadline = os.clock() + ACK_TIMEOUT
|
|
571
|
-
while answer == nil and os.clock() < deadline do
|
|
572
|
-
task.wait(0.05)
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
connection:Disconnect()
|
|
576
|
-
relay:Destroy()
|
|
552
|
+
local player = ClientRelay.playerFor(params.player)
|
|
577
553
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
554
|
+
--[[
|
|
555
|
+
The deadline grows with the plan. A flat 20s refused plans the schema
|
|
556
|
+
allows -- two 10s holds and a tap came back NO_ACK while the client was
|
|
557
|
+
still pressing keys. Each step also gets a little for cursor travel.
|
|
558
|
+
]]
|
|
559
|
+
local budget = ACK_TIMEOUT
|
|
560
|
+
for _, step in plan do
|
|
561
|
+
budget += (step.hold or 0) + (step.after or 0) + 0.5
|
|
585
562
|
end
|
|
563
|
+
local result = ClientRelay.run(player, "MCPInputRelay", RELAY_SOURCE, {
|
|
564
|
+
Plan = HttpService:JSONEncode(plan), Cursor = params.cursor ~= false, Budget = budget,
|
|
565
|
+
}, budget + 2)
|
|
586
566
|
|
|
587
|
-
local result = answer :: { [string]: any }
|
|
588
567
|
if result.ok ~= true then
|
|
589
568
|
Dispatch.fail("INPUT_FAILED", tostring(result.reason or "the client refused the plan"))
|
|
590
569
|
end
|