@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.
- package/README.md +20 -26
- package/dist/index.js +1 -1
- package/dist/lib/format.js +18 -2
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/pluginbuild.js +23 -13
- package/dist/lib/pluginbuild.js.map +1 -1
- package/dist/tools/api.js +20 -1
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/debug.js +18 -8
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/discover.js +5 -0
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/instances.js +49 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +11 -1
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +27 -3
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/session.js +16 -0
- package/dist/tools/session.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +257 -116
- package/plugin/src/Paths.luau +138 -69
- package/plugin/src/Serialize.luau +43 -3
- package/plugin/src/ThemePicker.luau +458 -0
- package/plugin/src/Themes/Aurora.luau +147 -0
- package/plugin/src/Themes/Blueprint.luau +213 -0
- package/plugin/src/Themes/Draw.luau +177 -0
- package/plugin/src/Themes/Lattice.luau +304 -0
- package/plugin/src/Themes/Nebula.luau +182 -0
- package/plugin/src/Themes/Observatory.luau +200 -0
- package/plugin/src/Themes/Orbit.luau +268 -0
- package/plugin/src/Themes/Phosphor.luau +200 -0
- package/plugin/src/Themes/Theme.luau +121 -0
- package/plugin/src/Themes/Void.luau +230 -0
- package/plugin/src/Themes/init.luau +116 -0
- package/plugin/src/Visuals.luau +788 -907
- package/plugin/src/handlers/Discover.luau +75 -1
- package/plugin/src/handlers/Exec.luau +17 -5
- package/plugin/src/handlers/Instances.luau +35 -3
- package/plugin/src/handlers/Scripts.luau +58 -2
- 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
|
|
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
|
-
--
|
|
59
|
-
--
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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 {
|
|
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()
|