@el4cteo/rbx-studio-mcp 0.7.0 → 0.7.2

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.
@@ -130,22 +130,6 @@ local function flatten(node: Instance, prefix: string, into: { { [string]: any }
130
130
  end
131
131
  end
132
132
 
133
- --[[
134
- Which rig an animation was made for, read off the joints it moves.
135
-
136
- This matters more than it sounds. An R15 animation played on an R6 character
137
- does nothing at all -- no error, no warning, no movement -- because the joint
138
- names it addresses ("LeftUpperArm") do not exist on that rig ("Left Arm").
139
- It is one of the quietest failures on the platform, and the asset id carries
140
- no hint of which kind it is.
141
-
142
- The two skeletons are told apart by naming rather than by counting. R6 uses
143
- six limbs with spaces in their names, unchanged since 2007. R15 splits each
144
- limb in two and writes them without spaces. Anything that matches neither is
145
- a custom rig, and is reported as such rather than guessed at -- a wrong
146
- confident answer here is worse than "unknown", because the whole point of
147
- the field is to be trusted.
148
- ]]
149
133
  --[[
150
134
  Which joint hangs off which, for the two rigs Roblox ships.
151
135
 
@@ -207,6 +191,22 @@ local R15_PARTS = {
207
191
  RightLowerArm = true,
208
192
  }
209
193
 
194
+ --[[
195
+ Which rig an animation was made for, read off the joints it moves.
196
+
197
+ This matters more than it sounds. An R15 animation played on an R6 character
198
+ does nothing at all -- no error, no warning, no movement -- because the joint
199
+ names it addresses ("LeftUpperArm") do not exist on that rig ("Left Arm").
200
+ It is one of the quietest failures on the platform, and the asset id carries
201
+ no hint of which kind it is.
202
+
203
+ The two skeletons are told apart by naming rather than by counting. R6 uses
204
+ six limbs with spaces in their names, unchanged since 2007. R15 splits each
205
+ limb in two and writes them without spaces. Anything that matches neither is
206
+ a custom rig, and is reported as such rather than guessed at -- a wrong
207
+ confident answer here is worse than "unknown", because the whole point of
208
+ the field is to be trusted.
209
+ ]]
210
210
  local function rigOf(joints: { [string]: boolean }): (string, string?)
211
211
  local r6, r15 = 0, 0
212
212
  for joint in joints do
@@ -418,21 +418,6 @@ function Anim.read(params: { [string]: any }): { [string]: any }
418
418
  }
419
419
  end
420
420
 
421
- --[[
422
- Builds a KeyframeSequence in memory and registers it for playback.
423
-
424
- The registration is what makes this more than an instance tree: it returns a
425
- content id that an Animation can use immediately, in this Studio, with no
426
- upload and no moderation wait. That is the whole loop an agent needs to try
427
- an idea -- build, set AnimationId, look -- and it is entirely reversible,
428
- because nothing left the session.
429
-
430
- Poses arrive flat, named by joint, and are nested under one root pose here.
431
- Flat is what a caller can write and nesting is what the engine resolves, and
432
- the rig's own hierarchy is not knowable from a list of joint names -- so a
433
- single root is the honest structure: it animates the named joints and claims
434
- nothing about how they connect.
435
- ]]
436
421
  --[[
437
422
  The joint tree to nest poses against.
438
423
 
@@ -477,6 +462,21 @@ local function parentMap(params: { [string]: any }, joints: { [string]: boolean
477
462
  return R6_PARENT, "R6"
478
463
  end
479
464
 
465
+ --[[
466
+ Builds a KeyframeSequence in memory and registers it for playback.
467
+
468
+ The registration is what makes this more than an instance tree: it returns a
469
+ content id that an Animation can use immediately, in this Studio, with no
470
+ upload and no moderation wait. That is the whole loop an agent needs to try
471
+ an idea -- build, set AnimationId, look -- and it is entirely reversible,
472
+ because nothing left the session.
473
+
474
+ Poses arrive flat, named by joint, and are nested under one root pose here.
475
+ Flat is what a caller can write and nesting is what the engine resolves, and
476
+ the rig's own hierarchy is not knowable from a list of joint names -- so a
477
+ single root is the honest structure: it animates the named joints and claims
478
+ nothing about how they connect.
479
+ ]]
480
480
  function Anim.build(params: { [string]: any }): { [string]: any }
481
481
  local frames = params.keyframes
482
482
  if typeof(frames) ~= "table" or #(frames :: { any }) == 0 then
@@ -636,25 +636,6 @@ function Anim.build(params: { [string]: any }): { [string]: any }
636
636
  }
637
637
  end
638
638
 
639
- --[[
640
- Plays an animation on a real rig, in edit mode, so it can be looked at.
641
-
642
- `read` says what an animation contains and `build` makes one; neither answers
643
- "does it look right", which for a piece of motion is most of the question.
644
- Measured: an `Animator` loads and plays a track in an ordinary edit session
645
- -- `Length` comes back populated and `IsPlaying` is true -- so the rig in the
646
- place can be posed without pressing Play.
647
-
648
- The frame is the point. Playing an animation and returning immediately shows
649
- whatever pose the rig happened to be in, so this seeks to a chosen moment and
650
- holds it there: play, jump to `at`, pause. Then `screenshot` sees that exact
651
- pose, and asking for three different moments gives three comparable frames.
652
-
653
- Deliberately leaves the rig posed rather than restoring it. The whole purpose
654
- is to leave something on screen to photograph, and a tool that tidied up
655
- before returning would always photograph the idle pose. `op="stop"` puts it
656
- back.
657
- ]]
658
639
  --[[
659
640
  Puts a rig back the way it was before anything was played on it.
660
641
 
@@ -722,6 +703,25 @@ local function restPose(model: Instance, animator: Animator)
722
703
  animator:StepAnimations(1 / 60)
723
704
  end
724
705
 
706
+ --[[
707
+ Plays an animation on a real rig, in edit mode, so it can be looked at.
708
+
709
+ `read` says what an animation contains and `build` makes one; neither answers
710
+ "does it look right", which for a piece of motion is most of the question.
711
+ Measured: an `Animator` loads and plays a track in an ordinary edit session
712
+ -- `Length` comes back populated and `IsPlaying` is true -- so the rig in the
713
+ place can be posed without pressing Play.
714
+
715
+ The frame is the point. Playing an animation and returning immediately shows
716
+ whatever pose the rig happened to be in, so this seeks to a chosen moment and
717
+ holds it there: play, jump to `at`, pause. Then `screenshot` sees that exact
718
+ pose, and asking for three different moments gives three comparable frames.
719
+
720
+ Deliberately leaves the rig posed rather than restoring it. The whole purpose
721
+ is to leave something on screen to photograph, and a tool that tidied up
722
+ before returning would always photograph the idle pose. `op="stop"` puts it
723
+ back.
724
+ ]]
725
725
  function Anim.preview(params: { [string]: any }): { [string]: any }
726
726
  local rigPath = params.rig
727
727
  if typeof(rigPath) ~= "string" or rigPath == "" then
@@ -178,14 +178,6 @@ local function takeControl(model: Model): boolean
178
178
  return ok
179
179
  end
180
180
 
181
- --[[
182
- Walks to a point, following a computed path around obstacles.
183
-
184
- Reports where it ended up rather than only whether the call returned, because
185
- `MoveTo` succeeds at being asked and says nothing about arriving. A route
186
- blocked by a wall the agent did not know about looks identical to a
187
- successful walk unless the final distance is measured.
188
- ]]
189
181
  --[[
190
182
  Agent settings, shared by the route check and the walk.
191
183
 
@@ -336,6 +328,14 @@ function Character.path(params: { [string]: any }): { [string]: any }
336
328
  }
337
329
  end
338
330
 
331
+ --[[
332
+ Walks to a point, following a computed path around obstacles.
333
+
334
+ Reports where it ended up rather than only whether the call returned, because
335
+ `MoveTo` succeeds at being asked and says nothing about arriving. A route
336
+ blocked by a wall the agent did not know about looks identical to a
337
+ successful walk unless the final distance is measured.
338
+ ]]
339
339
  function Character.moveTo(params: { [string]: any }): { [string]: any }
340
340
  local model, humanoid = humanoidOf(params)
341
341
 
@@ -184,10 +184,6 @@ function Discover.tree(params: { [string]: any }): { [string]: any }
184
184
  }
185
185
  end
186
186
 
187
- --[[
188
- Detailed read of specific instances. `properties` is chosen server-side from
189
- the live API dump, so this handler never needs its own class table.
190
- ]]
191
187
  --[[
192
188
  The physical facts about a part, which no property panel shows.
193
189
 
@@ -323,6 +319,10 @@ local function physicsOf(target: Instance): { [string]: any }?
323
319
  return facts
324
320
  end
325
321
 
322
+ --[[
323
+ Detailed read of specific instances. `properties` is chosen server-side from
324
+ the live API dump, so this handler never needs its own class table.
325
+ ]]
326
326
  function Discover.inspect(params: { [string]: any }): { [string]: any }
327
327
  local paths = params.paths
328
328
  if typeof(paths) ~= "table" or #paths == 0 then
@@ -415,13 +415,6 @@ function Discover.inspect(params: { [string]: any }): { [string]: any }
415
415
  return { items = results, failures = failures }
416
416
  end
417
417
 
418
- --[[
419
- One search over name, class, property value and tag.
420
-
421
- Every filter supplied must match (AND), which is what lets a single tool
422
- answer "every anchored Part under Workspace whose name contains 'door'"
423
- without the agent chaining three calls and intersecting the results itself.
424
- ]]
425
418
  --[[
426
419
  Runs a selector through the engine's own matcher.
427
420
 
@@ -582,6 +575,13 @@ function Discover.tags(params: { [string]: any }): { [string]: any }
582
575
  }
583
576
  end
584
577
 
578
+ --[[
579
+ One search over name, class, property value and tag.
580
+
581
+ Every filter supplied must match (AND), which is what lets a single tool
582
+ answer "every anchored Part under Workspace whose name contains 'door'"
583
+ without the agent chaining three calls and intersecting the results itself.
584
+ ]]
585
585
  function Discover.find(params: { [string]: any }): { [string]: any }
586
586
  local root = if params.path then Paths.resolve(params.path) else game
587
587
  local limit = tonumber(params.limit) or 100
@@ -301,18 +301,42 @@ end
301
301
 
302
302
  makeCursor()
303
303
 
304
- for _, step in plan do
304
+ --[[
305
+ Some keys cannot be sent at all. Roblox keeps them for its own menus --
306
+ Escape, Tab, F9, and in this place's case also One -- and `SendKey` THROWS
307
+ for them ("permanently bound to a CoreGUI core action"). Uncaught, that
308
+ killed this script mid-plan: the rest of the steps never ran, nothing was
309
+ reported, and the tool gave up with NO_ACK after its full timeout while the
310
+ error sat in Studio's Output. Which keys are reserved depends on the place
311
+ (One is the backpack hotbar), so it is caught here rather than listed.
312
+ ]]
313
+ local function refusedKey(key, err)
314
+ local message = tostring(err)
315
+ if string.find(message, "CoreGUI", 1, true) then
316
+ return string.format(
317
+ "%s was not sent: Roblox reserves it for its own menus, so no script can press it. "
318
+ .. "Test what the game does on that key by calling its handler another way.",
319
+ key
320
+ )
321
+ end
322
+ return string.format("%s was not sent: %s", key, message)
323
+ end
324
+
325
+ local function runStep(step): boolean
305
326
  local kind = step.kind
306
327
  if kind == "key" then
307
328
  local code = Enum.KeyCode[step.key]
308
- if step.action == "release" then
329
+ local down = step.action ~= "release"
330
+ local sent, err = pcall(function()
331
+ virtual:SendKey(down, code, false)
332
+ end)
333
+ if not sent then
334
+ table.insert(notes, refusedKey(step.key, err))
335
+ return false
336
+ end
337
+ if down and step.action ~= "press" then
338
+ task.wait(step.hold or 0.08)
309
339
  virtual:SendKey(false, code, false)
310
- else
311
- virtual:SendKey(true, code, false)
312
- if step.action ~= "press" then
313
- task.wait(step.hold or 0.08)
314
- virtual:SendKey(false, code, false)
315
- end
316
340
  end
317
341
  elseif kind == "move" then
318
342
  moveCursor(step.x, step.y, 0.3)
@@ -364,7 +388,17 @@ for _, step in plan do
364
388
  virtual:SendTextInput(step.text)
365
389
  end
366
390
  end
367
- table.insert(performed, kind)
391
+ return true
392
+ end
393
+
394
+ for index, step in plan do
395
+ -- Guarded so one failing step can never stop the report going back.
396
+ local ok, done = pcall(runStep, step)
397
+ if not ok then
398
+ table.insert(notes, string.format("step %d (%s) failed: %s", index - 1, tostring(step.kind), tostring(done)))
399
+ elseif done then
400
+ table.insert(performed, step.kind)
401
+ end
368
402
  if step.after ~= nil and step.after > 0 then
369
403
  task.wait(step.after)
370
404
  end
@@ -109,6 +109,11 @@ local function applyTags(target: Instance, tags: { [string]: any })
109
109
  end
110
110
  end
111
111
 
112
+ --[[
113
+ One property that could not be applied yet, kept for a second attempt.
114
+ ]]
115
+ type Deferred = { target: Instance, name: string, spec: PropertySpec, className: string }
116
+
112
117
  --[[
113
118
  Builds one instance and its descendants.
114
119
 
@@ -116,11 +121,6 @@ end
116
121
  model appears in the Explorer complete rather than assembling itself piece by
117
122
  piece in front of the user.
118
123
  ]]
119
- --[[
120
- One property that could not be applied yet, kept for a second attempt.
121
- ]]
122
- type Deferred = { target: Instance, name: string, spec: PropertySpec, className: string }
123
-
124
124
  local function build(
125
125
  spec: { [string]: any },
126
126
  parent: Instance,
@@ -56,11 +56,6 @@ local function resolveScript(path: string): LuaSourceContainer
56
56
  return instance :: LuaSourceContainer
57
57
  end
58
58
 
59
- --[[
60
- Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
61
- inclusive, matching the numbers script_edit takes back, so a read and a write
62
- need no off-by-one conversion between them.
63
- ]]
64
59
  --[[
65
60
  A short fingerprint of a script's source, for detecting that it moved.
66
61
 
@@ -136,6 +131,11 @@ local function syncedFile(target: Instance): string?
136
131
  return name
137
132
  end
138
133
 
134
+ --[[
135
+ Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
136
+ inclusive, matching the numbers script_edit takes back, so a read and a write
137
+ need no off-by-one conversion between them.
138
+ ]]
139
139
  function Scripts.read(params: { [string]: any }): { [string]: any }
140
140
  local paths = params.paths
141
141
  if typeof(paths) ~= "table" or #paths == 0 then
@@ -381,12 +381,45 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
381
381
  )
382
382
  end
383
383
 
384
+ local function badPattern(reason: any): never
385
+ Dispatch.fail(
386
+ "BAD_PATTERN",
387
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(reason)),
388
+ "Lua patterns escape with %, not backslash, and have no alternation. "
389
+ .. "Set `literal` to search for the text exactly as written."
390
+ )
391
+ end
392
+
393
+ --[[
394
+ One search over the whole file before splitting it into lines.
395
+
396
+ Most scripts in a place do not contain what is being looked for, and
397
+ splitting each into a line table only to find nothing was most of the cost
398
+ of a place-wide search. A pattern that matches no line cannot match the
399
+ whole file either -- except one anchored with `^` or `$`, which mean line
400
+ ends here but file ends there, so those skip this shortcut.
401
+ ]]
402
+ -- Any trailing `$` counts, escaped or not: skipping the shortcut costs speed,
403
+ -- taking it wrongly would hide matches.
404
+ local anchored = not literal and (string.sub(needle, 1, 1) == "^" or string.sub(needle, -1) == "$")
405
+
384
406
  local matches: { { [string]: any } } = {}
385
407
  local total = 0
386
408
  local memo: Paths.NameIndex = {}
387
409
 
388
410
  for _, target in targets do
389
- local lines = TextEdit.toLines(ScriptEdit.read(target))
411
+ local source = ScriptEdit.read(target)
412
+ if not anchored then
413
+ local ok, found = pcall(string.find, if ignoreCase then string.lower(source) else source, needle, 1, literal)
414
+ if not ok then
415
+ badPattern(found)
416
+ end
417
+ if not found then
418
+ continue
419
+ end
420
+ end
421
+
422
+ local lines = TextEdit.toLines(source)
390
423
  local path: string? = nil
391
424
 
392
425
  for number, line in lines do
@@ -395,12 +428,7 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
395
428
  -- has to be caught and reported as a pattern problem, not a no-match.
396
429
  local ok, from = pcall(string.find, haystack, needle, 1, literal)
397
430
  if not ok then
398
- Dispatch.fail(
399
- "BAD_PATTERN",
400
- string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
401
- "Lua patterns escape with %, not backslash, and have no alternation. "
402
- .. "Set `literal` to search for the text exactly as written."
403
- )
431
+ badPattern(from)
404
432
  end
405
433
  if not from then
406
434
  continue
@@ -440,12 +468,6 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
440
468
  }
441
469
  end
442
470
 
443
- --[[
444
- Creates scripts. Source is assigned directly here rather than through
445
- `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
446
- open in the editor and there is no buffer to conflict with. Every later edit
447
- goes through the editor path.
448
- ]]
449
471
  --[[
450
472
  Names the starter container a script was just parented into, or nil.
451
473
 
@@ -457,6 +479,14 @@ end
457
479
  Client over LocalScript" advice writes a double-running script and is given
458
480
  no way to find out.
459
481
  ]]
482
+ --[[
483
+ Assigning `Source` fails at 200,000 characters or more ("Provided string
484
+ length ... is greater than or equal to max length (200000)"), while
485
+ `UpdateSourceAsync` takes far more -- measured writing 279,000. Sources at
486
+ the limit are written through the editor instead of refused.
487
+ ]]
488
+ local SOURCE_PROPERTY_LIMIT = 200000
489
+
460
490
  local STARTER_CONTAINERS = {
461
491
  "StarterGui",
462
492
  "StarterPack",
@@ -473,6 +503,12 @@ local function starterContainer(instance: Instance): string?
473
503
  return nil
474
504
  end
475
505
 
506
+ --[[
507
+ Creates scripts. Source is assigned directly here rather than through
508
+ `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
509
+ open in the editor and there is no buffer to conflict with. Every later edit
510
+ goes through the editor path.
511
+ ]]
476
512
  function Scripts.create(params: { [string]: any }): { [string]: any }
477
513
  local requests = params.scripts
478
514
  if typeof(requests) ~= "table" or #requests == 0 then
@@ -503,6 +539,7 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
503
539
  -- No shared path memo here: each creation changes its parent's children, so a
504
540
  -- cached sibling grouping would go stale mid-batch and mis-number the paths.
505
541
  local warnings: { string } = {}
542
+ local oversized: { { target: LuaSourceContainer, source: string } } = {}
506
543
 
507
544
  local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
508
545
  local created: { { [string]: any } } = {}
@@ -513,7 +550,11 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
513
550
 
514
551
  instance.Name = request.name
515
552
  if typeof(request.source) == "string" then
516
- (instance :: ScriptEdit.SourceContainer).Source = request.source
553
+ if #request.source < SOURCE_PROPERTY_LIMIT then
554
+ (instance :: ScriptEdit.SourceContainer).Source = request.source
555
+ else
556
+ table.insert(oversized, { target = instance, source = request.source })
557
+ end
517
558
  end
518
559
 
519
560
  if typeof(request.runContext) == "string" and instance:IsA("Script") then
@@ -605,6 +646,14 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
605
646
  return created
606
647
  end)
607
648
 
649
+ -- After the recording, because the editor write yields. Undoing the create
650
+ -- still removes these scripts whole, source and all.
651
+ for _, entry in oversized do
652
+ ScriptEdit.write(entry.target, function()
653
+ return entry.source
654
+ end)
655
+ end
656
+
608
657
  return {
609
658
  items = created,
610
659
  undoStep = if recorded then "MCP create script" else nil,