@el4cteo/rbx-studio-mcp 0.7.2 → 0.7.6

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.
@@ -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 Serialize = require(script.Parent.Parent.Serialize)
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
- -- Captured rather than inferred: a chunk's prints are usually the point, and
247
- -- reading them back from the log afterwards would also pick up whatever else
248
- -- the place logged in the meantime.
249
- local output: { { [string]: any } } = {}
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 Players = game:GetService("Players")
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(if typeof(params.player) == "string" then params.player else nil)
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
- if answer == nil then
579
- Dispatch.fail(
580
- "NO_ACK",
581
- string.format("The client did not confirm the input within %ds.", ACK_TIMEOUT),
582
- "Nothing here can tell whether some of it was delivered. Read `character op=\"state\"` "
583
- .. "to see where things actually are."
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