@el4cteo/rbx-studio-mcp 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,78 @@
1
+ --!strict
2
+ --[[
3
+ Which parts of the DataModel a search is allowed to wander into.
4
+
5
+ `game:GetChildren()` returns ~122 instances, nearly all of them engine
6
+ singletons like TraceRouteService and HSRDataContentProvider that no one
7
+ authors content into. Listing them costs thousands of tokens and buries the
8
+ handful of containers that matter.
9
+
10
+ A "show it if it has children" rule was tried first and let too much through:
11
+ MemStorageService alone reports 197 children of pure engine state. So the root
12
+ level is an explicit allowlist of the containers a place is actually authored
13
+ in. Everything else stays reachable by passing its path directly, and callers
14
+ report how many were hidden so that is discoverable.
15
+ ]]
16
+
17
+ local Scope = {}
18
+
19
+ Scope.ROOT_ALWAYS = {
20
+ Workspace = true,
21
+ Players = true,
22
+ Lighting = true,
23
+ ReplicatedFirst = true,
24
+ ReplicatedStorage = true,
25
+ ServerScriptService = true,
26
+ ServerStorage = true,
27
+ StarterGui = true,
28
+ StarterPack = true,
29
+ StarterPlayer = true,
30
+ SoundService = true,
31
+ Teams = true,
32
+ MaterialService = true,
33
+ TextChatService = true,
34
+ Chat = true,
35
+ LocalizationService = true,
36
+ TestService = true,
37
+ }
38
+
39
+ --[[
40
+ The top-level service an instance sits under, or the instance itself when it
41
+ is one. Used to hide whole branches (everything inside CoreGui) rather than
42
+ just the service node.
43
+ ]]
44
+ function Scope.topService(instance: Instance): Instance?
45
+ if instance == game then
46
+ return nil
47
+ end
48
+
49
+ local current = instance
50
+ local parent = current.Parent
51
+ while parent ~= nil and parent ~= game do
52
+ current = parent
53
+ parent = current.Parent
54
+ end
55
+
56
+ -- Landing on `game` means `current` is the service. Landing on nil means the
57
+ -- instance is detached from the DataModel, which is not noise to filter.
58
+ if parent == game then
59
+ return current
60
+ end
61
+ return nil
62
+ end
63
+
64
+ function Scope.isVisibleService(service: Instance): boolean
65
+ return Scope.ROOT_ALWAYS[service.Name] == true
66
+ end
67
+
68
+ --[[
69
+ True for instances the caller almost certainly did not mean to see. Only
70
+ applied when walking from the DataModel root; an explicit `path` into a
71
+ hidden service is always honoured.
72
+ ]]
73
+ function Scope.isNoisy(instance: Instance): boolean
74
+ local service = Scope.topService(instance)
75
+ return service ~= nil and not Scope.isVisibleService(service)
76
+ end
77
+
78
+ return Scope
@@ -0,0 +1,100 @@
1
+ --!strict
2
+ --[[
3
+ Editor-safe script reading and writing.
4
+
5
+ Every other Roblox Studio MCP server assigns `script.Source` directly. That is
6
+ wrong in a specific, silent way: when the script is open in the Studio script
7
+ editor, the editor holds its own buffer, and a direct Source write either gets
8
+ overwritten by that buffer or discards the user's unsaved edits. Roblox added
9
+ `ScriptEditorService:UpdateSourceAsync` precisely to close this gap -- it
10
+ routes the change through the editor so the open document, its undo stack and
11
+ the saved source all stay in agreement.
12
+
13
+ The cost is that it yields, so every write here is async. That is the whole
14
+ reason handlers are allowed to yield.
15
+ ]]
16
+
17
+ local ScriptEditorService = game:GetService("ScriptEditorService")
18
+
19
+ local Dispatch = require(script.Parent.Dispatch)
20
+
21
+ local ScriptEdit = {}
22
+
23
+ export type SourceContainer = LuaSourceContainer & { Source: string }
24
+
25
+ --[[
26
+ Reads current source. `GetEditorSource` is preferred over `.Source` because it
27
+ returns the editor's live buffer, including edits the user has typed but not
28
+ saved -- reading `.Source` there would hand the agent stale code and it would
29
+ then "fix" changes the user just made.
30
+ ]]
31
+ function ScriptEdit.read(target: LuaSourceContainer): string
32
+ local ok, source = pcall(function()
33
+ return ScriptEditorService:GetEditorSource(target)
34
+ end)
35
+ if ok and typeof(source) == "string" then
36
+ return source
37
+ end
38
+ return (target :: SourceContainer).Source
39
+ end
40
+
41
+ --[[
42
+ Replaces source through the editor. `transform` receives the current text and
43
+ returns the replacement; returning the input unchanged is a no-op.
44
+
45
+ `transform` is allowed to raise a structured Dispatch failure -- a bad line
46
+ range is only discoverable once the authoritative source is in hand. Such a
47
+ failure is stashed rather than thrown from inside the callback: throwing
48
+ there would surface as an opaque engine error and lose the code and hint the
49
+ agent needs. The callback instead returns the text untouched, making that one
50
+ script a no-op, and the original failure is re-raised afterwards.
51
+
52
+ Raises SCRIPT_LOCKED when the write is refused, which normally means the
53
+ script is a package member or is owned by another Team Create session.
54
+ ]]
55
+ function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> string)
56
+ local pending: any = nil
57
+
58
+ local ok, err = pcall(function()
59
+ ScriptEditorService:UpdateSourceAsync(target, function(source)
60
+ local applied, result = pcall(transform, source)
61
+ if applied then
62
+ return result
63
+ end
64
+ pending = result
65
+ return source
66
+ end)
67
+ end)
68
+
69
+ if pending ~= nil then
70
+ error(pending, 0)
71
+ end
72
+ if ok then
73
+ return
74
+ end
75
+
76
+ local message = tostring(err)
77
+ if string.find(string.lower(message), "lock") or string.find(string.lower(message), "permission") then
78
+ Dispatch.fail(
79
+ "SCRIPT_LOCKED",
80
+ string.format("Studio refused to edit %s: %s", target:GetFullName(), message),
81
+ "The script is usually locked because it belongs to a package or is "
82
+ .. "checked out by another Team Create user. Ask the user to unlock it, "
83
+ .. "or edit a different script."
84
+ )
85
+ end
86
+ Dispatch.fail(
87
+ "EDIT_FAILED",
88
+ string.format("Could not edit %s: %s", target:GetFullName(), message)
89
+ )
90
+ end
91
+
92
+ --[[
93
+ True for instances that hold Luau source. Used to keep script tools from
94
+ being pointed at ordinary instances with a confusing property error.
95
+ ]]
96
+ function ScriptEdit.isScript(instance: Instance): boolean
97
+ return instance:IsA("LuaSourceContainer")
98
+ end
99
+
100
+ return ScriptEdit
@@ -0,0 +1,287 @@
1
+ --!strict
2
+ --[[
3
+ Converts Roblox values into JSON-safe, token-cheap representations.
4
+
5
+ Format mirrors what the Studio Properties panel shows -- Vector3 as
6
+ "12, 0, 5", Enums as "Enum.Material.Plastic", instance references as their
7
+ path. Two reasons: a Roblox developer reading the transcript sees what they
8
+ would see in Studio, and a model has seen far more of that notation in
9
+ training than any bespoke JSON envelope. It is also markedly cheaper than
10
+ {"__type":"Vector3","x":12,"y":0,"z":5} on every property of every instance.
11
+
12
+ `Serialize.parse` is the exact inverse, so a value read here can be written
13
+ straight back without the agent reformatting it.
14
+ ]]
15
+
16
+ local Serialize = {}
17
+
18
+ -- Floats are rounded before display: Studio reports positions like
19
+ -- 12.000000476837158, which costs tokens and means nothing to the caller.
20
+ local PRECISION = 4
21
+
22
+ -- API-dump type names that accept a plain Luau number.
23
+ local NUMERIC_TYPES: { [string]: boolean } = {
24
+ int = true,
25
+ int64 = true,
26
+ float = true,
27
+ double = true,
28
+ number = true,
29
+ }
30
+
31
+ local function num(value: number): string
32
+ local rounded = math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
33
+ if rounded == math.floor(rounded) and math.abs(rounded) < 1e15 then
34
+ return string.format("%d", rounded)
35
+ end
36
+ return tostring(rounded)
37
+ end
38
+
39
+ --[[
40
+ Serializes one value. Unknown userdata falls back to `tostring`, which is
41
+ always better than dropping the property silently.
42
+ ]]
43
+ function Serialize.value(value: any): any
44
+ local kind = typeof(value)
45
+
46
+ if kind == "string" or kind == "boolean" or kind == "nil" then
47
+ return value
48
+ end
49
+ if kind == "number" then
50
+ -- Kept numeric so the agent can do arithmetic without parsing.
51
+ return math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
52
+ end
53
+ if kind == "Vector3" then
54
+ return string.format("%s, %s, %s", num(value.X), num(value.Y), num(value.Z))
55
+ end
56
+ if kind == "Vector2" then
57
+ return string.format("%s, %s", num(value.X), num(value.Y))
58
+ end
59
+ if kind == "CFrame" then
60
+ local position = value.Position
61
+ local rx, ry, rz = value:ToOrientation()
62
+ return string.format(
63
+ "pos %s, %s, %s | rot %s, %s, %s",
64
+ num(position.X),
65
+ num(position.Y),
66
+ num(position.Z),
67
+ num(math.deg(rx)),
68
+ num(math.deg(ry)),
69
+ num(math.deg(rz))
70
+ )
71
+ end
72
+ if kind == "Color3" then
73
+ return string.format("%s, %s, %s", num(value.R), num(value.G), num(value.B))
74
+ end
75
+ if kind == "BrickColor" then
76
+ return value.Name
77
+ end
78
+ if kind == "UDim2" then
79
+ return string.format(
80
+ "{%s, %s}, {%s, %s}",
81
+ num(value.X.Scale),
82
+ num(value.X.Offset),
83
+ num(value.Y.Scale),
84
+ num(value.Y.Offset)
85
+ )
86
+ end
87
+ if kind == "UDim" then
88
+ return string.format("{%s, %s}", num(value.Scale), num(value.Offset))
89
+ end
90
+ if kind == "EnumItem" then
91
+ return string.format("Enum.%s.%s", tostring(value.EnumType), value.Name)
92
+ end
93
+ if kind == "Instance" then
94
+ return value:GetFullName()
95
+ end
96
+ if kind == "NumberRange" then
97
+ return string.format("%s..%s", num(value.Min), num(value.Max))
98
+ end
99
+ if kind == "Rect" then
100
+ return string.format(
101
+ "%s, %s, %s, %s",
102
+ num(value.Min.X),
103
+ num(value.Min.Y),
104
+ num(value.Max.X),
105
+ num(value.Max.Y)
106
+ )
107
+ end
108
+ if kind == "ColorSequence" then
109
+ local parts: { string } = {}
110
+ for _, keypoint in value.Keypoints do
111
+ table.insert(
112
+ parts,
113
+ string.format("%s=%s", num(keypoint.Time), Serialize.value(keypoint.Value))
114
+ )
115
+ end
116
+ return table.concat(parts, " ")
117
+ end
118
+ if kind == "NumberSequence" then
119
+ local parts: { string } = {}
120
+ for _, keypoint in value.Keypoints do
121
+ table.insert(parts, string.format("%s=%s", num(keypoint.Time), num(keypoint.Value)))
122
+ end
123
+ return table.concat(parts, " ")
124
+ end
125
+ if kind == "table" then
126
+ local copy: { [any]: any } = {}
127
+ for key, item in value :: { [any]: any } do
128
+ copy[key] = Serialize.value(item)
129
+ end
130
+ return copy
131
+ end
132
+
133
+ return tostring(value)
134
+ end
135
+
136
+ --[[
137
+ Reads a property without throwing.
138
+
139
+ Property access on an instance can error even when the API dump says the
140
+ property exists -- some are context-gated (only readable during a playtest,
141
+ or only on a loaded asset). Returning nil for those keeps one awkward
142
+ property from failing an inspect of 50 instances.
143
+ ]]
144
+ function Serialize.readProperty(instance: Instance, name: string): (boolean, any)
145
+ local ok, value = pcall(function()
146
+ return (instance :: any)[name]
147
+ end)
148
+ if not ok then
149
+ return false, nil
150
+ end
151
+ return true, Serialize.value(value)
152
+ end
153
+
154
+ --[[
155
+ Parses a serialized string back into a Roblox value, given the target type
156
+ from the API dump. Returns ok=false with a reason the agent can act on
157
+ rather than raising, so batch writes can report per-property failures.
158
+ ]]
159
+ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
160
+ local kind = typeof(text)
161
+
162
+ -- Values already of the right primitive shape pass straight through. The
163
+ -- number case is matched against the target type rather than excluded from
164
+ -- one: a bare 5 is a valid `float`, but not a valid Vector3, Enum or Color3.
165
+ if kind == "boolean" then
166
+ return true, text, nil
167
+ end
168
+ if kind == "number" and NUMERIC_TYPES[valueType] then
169
+ return true, text, nil
170
+ end
171
+
172
+ if valueType == "string" or valueType == "Content" or valueType == "ProtectedString" then
173
+ return true, tostring(text), nil
174
+ end
175
+ if valueType == "bool" then
176
+ if kind == "boolean" then
177
+ return true, text, nil
178
+ end
179
+ return true, text == "true", nil
180
+ end
181
+ if NUMERIC_TYPES[valueType] then
182
+ local parsed = tonumber(text)
183
+ if not parsed then
184
+ return false, nil, string.format('expected a number, got "%s"', tostring(text))
185
+ end
186
+ return true, parsed, nil
187
+ end
188
+
189
+ local numbers: { number } = {}
190
+ if kind == "string" then
191
+ for match in string.gmatch(text, "-?%d+%.?%d*") do
192
+ table.insert(numbers, tonumber(match) :: number)
193
+ end
194
+ elseif kind == "table" then
195
+ for _, item in text :: { any } do
196
+ local parsed = tonumber(item)
197
+ if parsed then
198
+ table.insert(numbers, parsed)
199
+ end
200
+ end
201
+ end
202
+
203
+ if valueType == "Vector3" then
204
+ if #numbers < 3 then
205
+ return false, nil, 'expected three numbers, e.g. "12, 0, 5"'
206
+ end
207
+ return true, Vector3.new(numbers[1], numbers[2], numbers[3]), nil
208
+ end
209
+ if valueType == "Vector2" then
210
+ if #numbers < 2 then
211
+ return false, nil, 'expected two numbers, e.g. "12, 5"'
212
+ end
213
+ return true, Vector2.new(numbers[1], numbers[2]), nil
214
+ end
215
+ if valueType == "Color3" then
216
+ if #numbers < 3 then
217
+ return false, nil, 'expected three 0-1 components, e.g. "1, 0.5, 0"'
218
+ end
219
+ -- 0-255 is a common mistake and unambiguous to detect, so accept it.
220
+ local scale = if numbers[1] > 1 or numbers[2] > 1 or numbers[3] > 1 then 255 else 1
221
+ return true, Color3.new(numbers[1] / scale, numbers[2] / scale, numbers[3] / scale), nil
222
+ end
223
+ if valueType == "BrickColor" then
224
+ -- Cast because the API definitions type this overload as a literal union
225
+ -- of every BrickColor name. At runtime it takes any string and throws on
226
+ -- an unknown one, which is exactly what the pcall is here to catch.
227
+ local ok, color = pcall(function()
228
+ return BrickColor.new(tostring(text) :: any)
229
+ end)
230
+ if not ok then
231
+ return false, nil, string.format('"%s" is not a BrickColor name', tostring(text))
232
+ end
233
+ return true, color, nil
234
+ end
235
+ if valueType == "UDim2" then
236
+ if #numbers < 4 then
237
+ return false, nil, 'expected four numbers, e.g. "{0.5, 10}, {0.5, 20}"'
238
+ end
239
+ return true, UDim2.new(numbers[1], numbers[2], numbers[3], numbers[4]), nil
240
+ end
241
+ if valueType == "UDim" then
242
+ if #numbers < 2 then
243
+ return false, nil, 'expected two numbers, e.g. "{0.5, 10}"'
244
+ end
245
+ return true, UDim.new(numbers[1], numbers[2]), nil
246
+ end
247
+ if valueType == "CFrame" then
248
+ if #numbers >= 6 then
249
+ return true,
250
+ CFrame.new(numbers[1], numbers[2], numbers[3])
251
+ * CFrame.fromOrientation(
252
+ math.rad(numbers[4]),
253
+ math.rad(numbers[5]),
254
+ math.rad(numbers[6])
255
+ ),
256
+ nil
257
+ end
258
+ if #numbers >= 3 then
259
+ return true, CFrame.new(numbers[1], numbers[2], numbers[3]), nil
260
+ end
261
+ return false, nil, 'expected "pos x, y, z" or "pos x, y, z | rot rx, ry, rz"'
262
+ end
263
+ if valueType == "NumberRange" then
264
+ if #numbers < 2 then
265
+ return false, nil, 'expected "min..max"'
266
+ end
267
+ return true, NumberRange.new(numbers[1], numbers[2]), nil
268
+ end
269
+
270
+ -- Enum values arrive as "Enum.Material.Plastic" or bare "Plastic".
271
+ local enumType = (Enum :: any)[valueType]
272
+ if enumType then
273
+ local name = tostring(text):match("([^%.]+)$") or tostring(text)
274
+ for _, item in enumType:GetEnumItems() do
275
+ if item.Name == name then
276
+ return true, item, nil
277
+ end
278
+ end
279
+ return false,
280
+ nil,
281
+ string.format('"%s" is not a member of Enum.%s', tostring(text), valueType)
282
+ end
283
+
284
+ return false, nil, string.format('no conversion known for type "%s"', valueType)
285
+ end
286
+
287
+ return Serialize