@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,389 @@
1
+ --!strict
2
+ --[[
3
+ Creating, changing, moving and deleting instances.
4
+
5
+ Every operation here is a batch wrapped in one ChangeHistoryService recording.
6
+ Unlike script source -- where a recording turns out not to cover the change at
7
+ all -- instance edits are genuinely captured, so a failed batch really does
8
+ roll back and a successful one really is a single Ctrl+Z. The response says
9
+ which of those happened rather than assuming.
10
+
11
+ Property values arrive as text with the target type resolved from the live
12
+ API dump on the server. That split matters: the plugin has no dump, and
13
+ guessing a type from whatever the property currently holds fails on exactly
14
+ the properties worth setting -- a nil PrimaryPart, an unset Color3.
15
+ ]]
16
+
17
+ local CollectionService = game:GetService("CollectionService")
18
+
19
+ local Dispatch = require(script.Parent.Parent.Dispatch)
20
+ local Paths = require(script.Parent.Parent.Paths)
21
+ local Serialize = require(script.Parent.Parent.Serialize)
22
+ local Undo = require(script.Parent.Parent.Undo)
23
+
24
+ -- Guards a runaway batch from locking Studio's main thread.
25
+ local MAX_INSTANCES = 500
26
+
27
+ local Instances = {}
28
+
29
+ type PropertySpec = { value: any, type: string }
30
+
31
+ --[[
32
+ Applies one property, returning a reason rather than raising so a batch can
33
+ report exactly which of twenty properties was wrong.
34
+ ]]
35
+ local function applyProperty(target: Instance, name: string, spec: PropertySpec): string?
36
+ if typeof(spec) ~= "table" or spec.type == nil then
37
+ return string.format("%s: no type was resolved for this property", name)
38
+ end
39
+
40
+ local ok, parsed, reason = Serialize.parse(spec.value, spec.type)
41
+ if not ok then
42
+ return string.format("%s: %s", name, reason or "could not be converted")
43
+ end
44
+
45
+ local assigned, err = pcall(function()
46
+ (target :: any)[name] = parsed
47
+ end)
48
+ if not assigned then
49
+ -- Read-only and context-gated properties fail here rather than at parse
50
+ -- time; the engine message names which.
51
+ return string.format("%s: %s", name, tostring(err))
52
+ end
53
+ return nil
54
+ end
55
+
56
+ local function applyAttributes(target: Instance, attributes: { [string]: any }): { string }
57
+ local failures: { string } = {}
58
+ 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.
61
+ local ok, err = pcall(function()
62
+ target:SetAttribute(name, if value == "" then nil else value)
63
+ end)
64
+ if not ok then
65
+ table.insert(failures, string.format("attribute %s: %s", name, tostring(err)))
66
+ end
67
+ end
68
+ return failures
69
+ end
70
+
71
+ local function applyTags(target: Instance, tags: { [string]: any })
72
+ for _, tag in (tags.add or {}) :: { string } do
73
+ CollectionService:AddTag(target, tag)
74
+ end
75
+ for _, tag in (tags.remove or {}) :: { string } do
76
+ CollectionService:RemoveTag(target, tag)
77
+ end
78
+ end
79
+
80
+ --[[
81
+ Builds one instance and its descendants.
82
+
83
+ Children are created before the parent is attached to the data model, so a
84
+ model appears in the Explorer complete rather than assembling itself piece by
85
+ piece in front of the user.
86
+ ]]
87
+ local function build(spec: { [string]: any }, parent: Instance, created: { Instance })
88
+ local ok, instance = pcall(Instance.new, spec.className)
89
+ if not ok or typeof(instance) ~= "Instance" then
90
+ Dispatch.fail(
91
+ "BAD_CLASS",
92
+ string.format('"%s" is not a creatable class.', tostring(spec.className)),
93
+ "Check the spelling and capitalisation. Abstract classes like BasePart "
94
+ .. "cannot be created -- use a concrete one such as Part."
95
+ )
96
+ end
97
+
98
+ local target = instance :: Instance
99
+ if typeof(spec.name) == "string" and spec.name ~= "" then
100
+ target.Name = spec.name
101
+ end
102
+
103
+ local failures: { string } = {}
104
+ for name, property in (spec.properties or {}) :: { [string]: PropertySpec } do
105
+ local failure = applyProperty(target, name, property)
106
+ if failure then
107
+ table.insert(failures, failure)
108
+ end
109
+ end
110
+ for _, failure in applyAttributes(target, (spec.attributes or {}) :: { [string]: any }) do
111
+ table.insert(failures, failure)
112
+ end
113
+ if spec.tags then
114
+ applyTags(target, spec.tags)
115
+ end
116
+
117
+ if #failures > 0 then
118
+ target:Destroy()
119
+ Dispatch.fail(
120
+ "BAD_PROPERTY",
121
+ string.format("Could not set %d value(s) on the new %s.", #failures, spec.className),
122
+ table.concat(failures, "; ")
123
+ )
124
+ end
125
+
126
+ -- Recorded parent-first, so the response reads top-down like the Explorer.
127
+ table.insert(created, target)
128
+ for _, child in (spec.children or {}) :: { { [string]: any } } do
129
+ build(child, target, created)
130
+ end
131
+
132
+ target.Parent = parent
133
+ end
134
+
135
+ function Instances.create(params: { [string]: any }): { [string]: any }
136
+ local specs = params.instances
137
+ if typeof(specs) ~= "table" or #specs == 0 then
138
+ Dispatch.fail(
139
+ "BAD_PARAMS",
140
+ "create requires a non-empty `instances` array.",
141
+ "Each entry needs `parent` and `className`."
142
+ )
143
+ end
144
+ if #specs > MAX_INSTANCES then
145
+ Dispatch.fail(
146
+ "TOO_MANY",
147
+ string.format("%d instances in one call, over the %d limit.", #specs, MAX_INSTANCES),
148
+ "Split the batch, or create one parent and nest the rest under `children`."
149
+ )
150
+ end
151
+
152
+ local created, recorded = Undo.record("StudioMCP.Create", "MCP create", function()
153
+ local built: { Instance } = {}
154
+ for _, spec in specs do
155
+ build(spec, Paths.resolve(spec.parent), built)
156
+ end
157
+ return built
158
+ end)
159
+
160
+ -- Paths are read only once the whole batch is attached. Formatting them as
161
+ -- each instance is made produces addresses that do not resolve: a child is
162
+ -- still detached from the data model while its parent is being assembled, and
163
+ -- a name shared with a sibling created later reads as unique because that
164
+ -- sibling does not exist yet.
165
+ local items: { { [string]: any } } = {}
166
+ local memo: Paths.NameIndex = {}
167
+ for _, instance in created do
168
+ table.insert(items, { path = Paths.of(instance, memo), className = instance.ClassName })
169
+ end
170
+
171
+ return {
172
+ items = items,
173
+ undoStep = if recorded then "MCP create" else nil,
174
+ }
175
+ end
176
+
177
+ function Instances.modify(params: { [string]: any }): { [string]: any }
178
+ local targets = params.targets
179
+ if typeof(targets) ~= "table" or #targets == 0 then
180
+ Dispatch.fail(
181
+ "BAD_PARAMS",
182
+ "modify requires a non-empty `targets` array.",
183
+ "Each entry needs `paths` plus `properties`, `attributes` or `tags`."
184
+ )
185
+ end
186
+
187
+ -- Resolve everything before opening a recording, so a typo in the last path
188
+ -- costs nothing and leaves no undo entry behind.
189
+ local resolved: { { instance: Instance, spec: { [string]: any } } } = {}
190
+ for _, entry in targets do
191
+ local paths = entry.paths
192
+ if typeof(paths) ~= "table" or #paths == 0 then
193
+ Dispatch.fail("BAD_PARAMS", "Every modify target needs a non-empty `paths` array.")
194
+ end
195
+ for _, path in paths :: { string } do
196
+ table.insert(resolved, { instance = Paths.resolve(path), spec = entry })
197
+ end
198
+ end
199
+
200
+ local changed, recorded = Undo.record("StudioMCP.Modify", "MCP modify", function()
201
+ local items: { { [string]: any } } = {}
202
+ local memo: Paths.NameIndex = {}
203
+
204
+ for _, target in resolved do
205
+ local failures: { string } = {}
206
+ local applied = 0
207
+
208
+ for name, property in (target.spec.properties or {}) :: { [string]: PropertySpec } do
209
+ local failure = applyProperty(target.instance, name, property)
210
+ if failure then
211
+ table.insert(failures, failure)
212
+ else
213
+ applied += 1
214
+ end
215
+ end
216
+
217
+ local attributes = (target.spec.attributes or {}) :: { [string]: any }
218
+ for _, failure in applyAttributes(target.instance, attributes) do
219
+ table.insert(failures, failure)
220
+ end
221
+ for _ in attributes do
222
+ applied += 1
223
+ end
224
+
225
+ if target.spec.tags then
226
+ applyTags(target.instance, target.spec.tags)
227
+ end
228
+
229
+ -- One bad property fails the whole batch: the recording is cancelled
230
+ -- and every instance reverts, which is far easier to reason about
231
+ -- than a place half-way between two states.
232
+ if #failures > 0 then
233
+ Dispatch.fail(
234
+ "BAD_PROPERTY",
235
+ string.format(
236
+ "Could not set %d value(s) on %s.",
237
+ #failures,
238
+ target.instance:GetFullName()
239
+ ),
240
+ table.concat(failures, "; ")
241
+ .. "\nNothing was changed: the whole batch was rolled back."
242
+ )
243
+ end
244
+
245
+ table.insert(items, {
246
+ path = Paths.of(target.instance, memo),
247
+ className = target.instance.ClassName,
248
+ changed = applied,
249
+ })
250
+ end
251
+ return items
252
+ end)
253
+
254
+ return {
255
+ items = changed,
256
+ undoStep = if recorded then "MCP modify" else nil,
257
+ }
258
+ end
259
+
260
+ function Instances.delete(params: { [string]: any }): { [string]: any }
261
+ local paths = params.paths
262
+ if typeof(paths) ~= "table" or #paths == 0 then
263
+ Dispatch.fail("BAD_PARAMS", "delete requires a non-empty `paths` array.")
264
+ end
265
+
266
+ local doomed: { Instance } = {}
267
+ for _, path in paths :: { string } do
268
+ local target = Paths.resolve(path)
269
+ -- Services cannot be destroyed and the attempt leaves Studio in a strange
270
+ -- state, so this is refused rather than passed to the engine.
271
+ if target == game or target.Parent == game then
272
+ Dispatch.fail(
273
+ "PROTECTED",
274
+ string.format('"%s" is a service and cannot be deleted.', path),
275
+ "Delete the contents instead, e.g. its children by path."
276
+ )
277
+ end
278
+ table.insert(doomed, target)
279
+ end
280
+
281
+ -- Paths are captured before anything is destroyed: an instance that has been
282
+ -- removed from the data model can no longer describe where it was.
283
+ local removed: { { [string]: any } } = {}
284
+ local memo: Paths.NameIndex = {}
285
+ for _, target in doomed do
286
+ table.insert(removed, {
287
+ path = Paths.of(target, memo),
288
+ className = target.ClassName,
289
+ descendants = #target:GetDescendants(),
290
+ })
291
+ end
292
+
293
+ local _, recorded = Undo.record("StudioMCP.Delete", "MCP delete", function()
294
+ for _, target in doomed do
295
+ target:Destroy()
296
+ end
297
+ return true
298
+ end)
299
+
300
+ return {
301
+ items = removed,
302
+ undoStep = if recorded then "MCP delete" else nil,
303
+ }
304
+ end
305
+
306
+ function Instances.move(params: { [string]: any }): { [string]: any }
307
+ local items = params.items
308
+ if typeof(items) ~= "table" or #items == 0 then
309
+ Dispatch.fail(
310
+ "BAD_PARAMS",
311
+ "move requires a non-empty `items` array.",
312
+ "Each entry needs `path` and `to`."
313
+ )
314
+ end
315
+
316
+ local planned: { { source: Instance, destination: Instance, spec: { [string]: any } } } = {}
317
+ for _, item in items do
318
+ local source = Paths.resolve(item.path)
319
+ local destination = Paths.resolve(item.to)
320
+
321
+ -- Reparenting something into itself detaches that whole branch from the
322
+ -- data model with no error, and it is not recoverable by undo.
323
+ if item.mode ~= "clone" and (destination == source or destination:IsDescendantOf(source)) then
324
+ Dispatch.fail(
325
+ "BAD_MOVE",
326
+ string.format('Cannot move "%s" into itself or its own descendant.', item.path),
327
+ "Pick a destination outside the branch being moved."
328
+ )
329
+ end
330
+ table.insert(planned, { source = source, destination = destination, spec = item })
331
+ end
332
+
333
+ local moved, recorded = Undo.record("StudioMCP.Move", "MCP move", function()
334
+ local results: { { instance: Instance, cloned: boolean } } = {}
335
+ for _, entry in planned do
336
+ local target = entry.source
337
+ if entry.spec.mode == "clone" then
338
+ -- Clone is typed as returning an Instance but hands back nil when
339
+ -- Archivable is false, which is how a locked or package-owned
340
+ -- instance refuses to be copied.
341
+ local copy = target:Clone()
342
+ if typeof(copy) ~= "Instance" then
343
+ Dispatch.fail(
344
+ "NOT_COPYABLE",
345
+ string.format('"%s" cannot be cloned.', target:GetFullName()),
346
+ "Archivable is false on this instance, which excludes it from copies."
347
+ )
348
+ end
349
+ target = copy
350
+ end
351
+
352
+ if typeof(entry.spec.name) == "string" and entry.spec.name ~= "" then
353
+ target.Name = entry.spec.name
354
+ end
355
+ target.Parent = entry.destination
356
+
357
+ table.insert(results, { instance = target, cloned = entry.spec.mode == "clone" })
358
+ end
359
+ return results
360
+ end)
361
+
362
+ -- As with create: a path formatted mid-batch can miss same-named siblings the
363
+ -- rest of the batch is about to add, so it is read once everything has landed.
364
+ local placed: { { [string]: any } } = {}
365
+ local memo: Paths.NameIndex = {}
366
+ for _, entry in moved do
367
+ table.insert(placed, {
368
+ path = Paths.of(entry.instance, memo),
369
+ className = entry.instance.ClassName,
370
+ cloned = entry.cloned,
371
+ })
372
+ end
373
+
374
+ return {
375
+ items = placed,
376
+ undoStep = if recorded then "MCP move" else nil,
377
+ }
378
+ end
379
+
380
+ function Instances.register()
381
+ Dispatch.registerAll("instances", {
382
+ create = Instances.create,
383
+ modify = Instances.modify,
384
+ delete = Instances.delete,
385
+ move = Instances.move,
386
+ })
387
+ end
388
+
389
+ return Instances