@el4cteo/rbx-studio-mcp 0.3.0 → 0.3.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.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/lib/pluginbuild.js +23 -13
  6. package/dist/lib/pluginbuild.js.map +1 -1
  7. package/dist/tools/api.js +20 -1
  8. package/dist/tools/api.js.map +1 -1
  9. package/dist/tools/debug.js +18 -8
  10. package/dist/tools/debug.js.map +1 -1
  11. package/dist/tools/discover.js +5 -0
  12. package/dist/tools/discover.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +11 -1
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +27 -3
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/dist/tools/session.js +16 -0
  20. package/dist/tools/session.js.map +1 -1
  21. package/package.json +1 -1
  22. package/plugin/src/Config.luau +1 -1
  23. package/plugin/src/Console.luau +257 -116
  24. package/plugin/src/Paths.luau +138 -69
  25. package/plugin/src/Serialize.luau +43 -3
  26. package/plugin/src/ThemePicker.luau +458 -0
  27. package/plugin/src/Themes/Aurora.luau +147 -0
  28. package/plugin/src/Themes/Blueprint.luau +213 -0
  29. package/plugin/src/Themes/Draw.luau +177 -0
  30. package/plugin/src/Themes/Lattice.luau +304 -0
  31. package/plugin/src/Themes/Nebula.luau +182 -0
  32. package/plugin/src/Themes/Observatory.luau +200 -0
  33. package/plugin/src/Themes/Orbit.luau +268 -0
  34. package/plugin/src/Themes/Phosphor.luau +200 -0
  35. package/plugin/src/Themes/Theme.luau +121 -0
  36. package/plugin/src/Themes/Void.luau +230 -0
  37. package/plugin/src/Themes/init.luau +116 -0
  38. package/plugin/src/Visuals.luau +788 -907
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Instances.luau +35 -3
  42. package/plugin/src/handlers/Scripts.luau +58 -2
  43. package/plugin/src/init.server.luau +386 -354
@@ -39,6 +39,72 @@ local function summarise(instance: Instance, memo: Paths.NameIndex): { [string]:
39
39
  }
40
40
  end
41
41
 
42
+ --[[
43
+ Puts a result list into an order that does not change between calls.
44
+
45
+ `GetChildren` and `GetDescendants` do NOT return a stable order. Measured on
46
+ twelve parts named P01..P12 with nothing mutating between the two calls, the
47
+ engine returned them in ascending order once and as P08, P06, P04, P03, P11,
48
+ P02, P01, P12, P10, P07, P05, P09 the next time.
49
+
50
+ Unsorted, that made paging incoherent rather than merely untidy: `offset` is
51
+ a position in this list, so page two came from a different ordering than page
52
+ one, which silently skips some instances and repeats others. Nothing about
53
+ the response would have said so -- the counts stay right and every row is a
54
+ real instance.
55
+
56
+ Sorted by name, with the engine's per-instance debug id breaking ties so
57
+ same-named siblings hold a fixed order too. Name first because it is also
58
+ the order a reader expects: P01 before P02.
59
+ ]]
60
+ local function stableOrder(list: { Instance })
61
+ local ids: { [Instance]: string } = {}
62
+ for _, instance in list do
63
+ -- Cached: `GetDebugId` is a call per comparison otherwise, and a sort
64
+ -- makes O(n log n) of them.
65
+ local ok, id = pcall(function()
66
+ return instance:GetDebugId()
67
+ end)
68
+ ids[instance] = if ok then id else tostring(instance)
69
+ end
70
+ table.sort(list, function(a: Instance, b: Instance): boolean
71
+ if a.Name ~= b.Name then
72
+ return a.Name < b.Name
73
+ end
74
+ return (ids[a] or "") < (ids[b] or "")
75
+ end)
76
+ end
77
+
78
+ --[[
79
+ Compares a property against the text a caller asked for.
80
+
81
+ This was an exact `tostring` match, which made `find` disagree with `modify`
82
+ about its own notation: `modify` documents an enum as either "Neon" or
83
+ "Enum.Material.Neon", but only the qualified form matched here, and "False"
84
+ matched nothing at all. Both came back as a confident zero rather than an
85
+ error, which is the hardest kind of wrong answer to notice.
86
+
87
+ The bare-name shortcut is allowed only for enums, so a part named
88
+ "Map.Backup" is not matched by a search for "Backup".
89
+ ]]
90
+ local function valueMatches(actual: any, wanted: string): boolean
91
+ local text = tostring(actual)
92
+ if text == wanted then
93
+ return true
94
+ end
95
+
96
+ local lowered = string.lower(text)
97
+ local target = string.lower(wanted)
98
+ if lowered == target then
99
+ return true
100
+ end
101
+
102
+ if string.sub(lowered, 1, 5) == "enum." then
103
+ return string.match(lowered, "([^%.]+)$") == target
104
+ end
105
+ return false
106
+ end
107
+
42
108
  --[[
43
109
  Breadth-first walk to `depth`, newest level last, so a truncated result is
44
110
  still a coherent picture of the top of the tree rather than one deep spur.
@@ -86,12 +152,18 @@ function Discover.tree(params: { [string]: any }): { [string]: any }
86
152
  end
87
153
  end
88
154
  end
155
+ -- Sorted per level rather than at the end, so the breadth-first shape is
156
+ -- kept -- shallow instances still come before deep ones -- while the
157
+ -- siblings inside each level stop reshuffling between calls.
158
+ stableOrder(nextFrontier)
89
159
  frontier = nextFrontier
90
160
  if #frontier == 0 or visits > MAX_VISITS then
91
161
  break
92
162
  end
93
163
  end
94
164
 
165
+ stableOrder(matched)
166
+
95
167
  local items: { { [string]: any } } = {}
96
168
  local memo: Paths.NameIndex = {}
97
169
  for index = offset + 1, math.min(offset + limit, #matched) do
@@ -257,13 +329,15 @@ function Discover.find(params: { [string]: any }): { [string]: any }
257
329
  if not readOk then
258
330
  continue
259
331
  end
260
- if propertyValue ~= nil and tostring(value) ~= tostring(propertyValue) then
332
+ if propertyValue ~= nil and not valueMatches(value, tostring(propertyValue)) then
261
333
  continue
262
334
  end
263
335
  end
264
336
  table.insert(matched, instance)
265
337
  end
266
338
 
339
+ stableOrder(matched)
340
+
267
341
  local items: { { [string]: any } } = {}
268
342
  local memo: Paths.NameIndex = {}
269
343
  for index = offset + 1, math.min(offset + limit, #matched) do
@@ -43,8 +43,9 @@ local Exec = {}
43
43
  serializer the rest of the server uses, so a Vector3 reads the way it does
44
44
  everywhere else.
45
45
  ]]
46
- local function describe(value: any, depth: number?): any
46
+ local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
47
47
  local level = depth or 0
48
+ local visited = seen or {}
48
49
  local kind = typeof(value)
49
50
 
50
51
  if kind == "function" or kind == "thread" then
@@ -55,11 +56,18 @@ local function describe(value: any, depth: number?): any
55
56
  end
56
57
 
57
58
  local source = value :: { [any]: any }
58
- -- Also the cycle guard: a self-referencing table stops here rather than
59
- -- recursing until the stack gives out.
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
60
67
  if level >= MAX_TABLE_DEPTH then
61
68
  return "<nested table>"
62
69
  end
70
+ visited[source] = true
63
71
 
64
72
  local count = 0
65
73
  for _ in source do
@@ -75,8 +83,11 @@ local function describe(value: any, depth: number?): any
75
83
  table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
76
84
  break
77
85
  end
78
- table.insert(list, describe(item, level + 1))
86
+ table.insert(list, describe(item, level + 1, visited))
79
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
80
91
  return list
81
92
  end
82
93
 
@@ -88,8 +99,9 @@ local function describe(value: any, depth: number?): any
88
99
  map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
89
100
  break
90
101
  end
91
- map[tostring(key)] = describe(item, level + 1)
102
+ map[tostring(key)] = describe(item, level + 1, visited)
92
103
  end
104
+ visited[source] = nil
93
105
  return map
94
106
  end
95
107
 
@@ -53,13 +53,45 @@ local function applyProperty(target: Instance, name: string, spec: PropertySpec)
53
53
  return nil
54
54
  end
55
55
 
56
+ --[[
57
+ Sets attributes, honouring an explicit type where one is given.
58
+
59
+ A bare value is written as it arrives, so a string stays a string. That is
60
+ the safe default and it is also a trap: attributes hold Vector3, Color3,
61
+ UDim2 and the rest, and `"0, 5, 0"` -- the exact text that sets a Vector3
62
+ property -- was landing on an attribute as five characters. The write looked
63
+ like it worked and the game read a string. So a value may instead arrive as
64
+ { type = "Vector3", value = "0, 5, 0" }, which goes through the same parser
65
+ properties use.
66
+ ]]
56
67
  local function applyAttributes(target: Instance, attributes: { [string]: any }): { string }
57
68
  local failures: { string } = {}
58
69
  for name, value in attributes do
59
- -- A JSON null means "remove this attribute", which SetAttribute spells
60
- -- as nil. There is no other way to express removal in the payload.
70
+ local resolved: any = value
71
+ local typed = typeof(value) == "table" and (value :: any).type ~= nil
72
+ if typed then
73
+ local spec = value :: { type: string, value: any }
74
+ local parsedOk, parsed, reason = Serialize.parse(spec.value, spec.type)
75
+ if not parsedOk then
76
+ table.insert(
77
+ failures,
78
+ string.format(
79
+ "attribute %s: %s",
80
+ name,
81
+ reason or string.format("could not be read as a %s", tostring(spec.type))
82
+ )
83
+ )
84
+ continue
85
+ end
86
+ resolved = parsed
87
+ end
88
+
89
+ -- An empty string means "remove this attribute", which SetAttribute spells
90
+ -- as nil. There is no other way to express removal in the payload -- and
91
+ -- the typed form is how to set an attribute to a genuinely empty string,
92
+ -- since only the bare form is read as removal.
61
93
  local ok, err = pcall(function()
62
- target:SetAttribute(name, if value == "" then nil else value)
94
+ target:SetAttribute(name, if not typed and value == "" then nil else resolved)
63
95
  end)
64
96
  if not ok then
65
97
  table.insert(failures, string.format("attribute %s: %s", name, tostring(err)))
@@ -324,6 +324,33 @@ end
324
324
  open in the editor and there is no buffer to conflict with. Every later edit
325
325
  goes through the editor path.
326
326
  ]]
327
+ --[[
328
+ Names the starter container a script was just parented into, or nil.
329
+
330
+ These four are copied into the player rather than run where they sit, so a
331
+ `Script` with a non-Legacy RunContext inside one runs BOTH in the original
332
+ and in every copy. Roblox does warn about it -- "will cause it to run
333
+ multiple times" -- but that warning is emitted by Studio itself and never
334
+ reaches `console`, so an agent following the "prefer Script with runContext
335
+ Client over LocalScript" advice writes a double-running script and is given
336
+ no way to find out.
337
+ ]]
338
+ local STARTER_CONTAINERS = {
339
+ "StarterGui",
340
+ "StarterPack",
341
+ "StarterPlayerScripts",
342
+ "StarterCharacterScripts",
343
+ }
344
+
345
+ local function starterContainer(instance: Instance): string?
346
+ for _, className in STARTER_CONTAINERS do
347
+ if instance:FindFirstAncestorOfClass(className :: any) then
348
+ return className
349
+ end
350
+ end
351
+ return nil
352
+ end
353
+
327
354
  function Scripts.create(params: { [string]: any }): { [string]: any }
328
355
  local requests = params.scripts
329
356
  if typeof(requests) ~= "table" or #requests == 0 then
@@ -343,13 +370,18 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
343
370
  "BAD_PARAMS",
344
371
  string.format('scripts[%d] has className "%s".', position, tostring(request.className)),
345
372
  "Use Script, LocalScript or ModuleScript. Prefer a Script with "
346
- .. "runContext Client over LocalScript in new work."
373
+ .. "runContext Client over LocalScript in new work -- except inside "
374
+ .. "StarterGui, StarterPack, StarterPlayerScripts or "
375
+ .. "StarterCharacterScripts, where LocalScript is still the right "
376
+ .. "class."
347
377
  )
348
378
  end
349
379
  end
350
380
 
351
381
  -- No shared path memo here: each creation changes its parent's children, so a
352
382
  -- cached sibling grouping would go stale mid-batch and mis-number the paths.
383
+ local warnings: { string } = {}
384
+
353
385
  local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
354
386
  local created: { { [string]: any } } = {}
355
387
 
@@ -381,6 +413,26 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
381
413
 
382
414
  instance.Parent = parent
383
415
 
416
+ if instance:IsA("Script") and instance.RunContext ~= Enum.RunContext.Legacy then
417
+ local container = starterContainer(instance)
418
+ if container then
419
+ table.insert(
420
+ warnings,
421
+ string.format(
422
+ '%s is a Script with RunContext %s inside %s. That container is '
423
+ .. "COPIED into each player, so the script runs once where it "
424
+ .. "sits and again in every copy. Make it a LocalScript "
425
+ .. "instead -- a Legacy Script there would not run at all. "
426
+ .. "Studio warns about this in its own Output, which `console` "
427
+ .. "cannot read.",
428
+ instance.Name,
429
+ instance.RunContext.Name,
430
+ container
431
+ )
432
+ )
433
+ end
434
+ end
435
+
384
436
  table.insert(created, {
385
437
  path = Paths.of(instance),
386
438
  className = instance.ClassName,
@@ -390,7 +442,11 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
390
442
  return created
391
443
  end)
392
444
 
393
- return { items = created, undoStep = if recorded then "MCP create script" else nil }
445
+ return {
446
+ items = created,
447
+ undoStep = if recorded then "MCP create script" else nil,
448
+ warnings = if #warnings > 0 then warnings else nil,
449
+ }
394
450
  end
395
451
 
396
452
  function Scripts.register()