@el4cteo/rbx-studio-mcp 0.6.5 → 0.6.7

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 (46) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/audio.js +96 -0
  19. package/dist/tools/audio.js.map +1 -0
  20. package/dist/tools/data.js +122 -3
  21. package/dist/tools/data.js.map +1 -1
  22. package/dist/tools/exec.js +48 -1
  23. package/dist/tools/exec.js.map +1 -1
  24. package/dist/tools/scripts.js +112 -4
  25. package/dist/tools/scripts.js.map +1 -1
  26. package/dist/tools/spatial.js +135 -0
  27. package/dist/tools/spatial.js.map +1 -0
  28. package/dist/tools/universe.js +177 -0
  29. package/dist/tools/universe.js.map +1 -0
  30. package/dist/tools/upload.js +294 -0
  31. package/dist/tools/upload.js.map +1 -0
  32. package/dist/tools/world.js +361 -10
  33. package/dist/tools/world.js.map +1 -1
  34. package/package.json +74 -74
  35. package/plugin/src/Commands.luau +646 -622
  36. package/plugin/src/Config.luau +1 -1
  37. package/plugin/src/Phrase.luau +816 -766
  38. package/plugin/src/Prompt.luau +965 -961
  39. package/plugin/src/Secret.luau +86 -0
  40. package/plugin/src/Serialize.luau +759 -499
  41. package/plugin/src/handlers/Assets.luau +636 -587
  42. package/plugin/src/handlers/Audio.luau +411 -0
  43. package/plugin/src/handlers/Geometry.luau +722 -577
  44. package/plugin/src/handlers/Instances.luau +84 -4
  45. package/plugin/src/handlers/Spatial.luau +334 -0
  46. package/plugin/src/init.server.luau +883 -879
@@ -116,7 +116,17 @@ end
116
116
  model appears in the Explorer complete rather than assembling itself piece by
117
117
  piece in front of the user.
118
118
  ]]
119
- local function build(spec: { [string]: any }, parent: Instance, created: { Instance })
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
+ local function build(
125
+ spec: { [string]: any },
126
+ parent: Instance,
127
+ created: { Instance },
128
+ deferred: { Deferred }
129
+ )
120
130
  local ok, instance = pcall(Instance.new, spec.className)
121
131
  if not ok or typeof(instance) ~= "Instance" then
122
132
  Dispatch.fail(
@@ -132,11 +142,37 @@ local function build(spec: { [string]: any }, parent: Instance, created: { Insta
132
142
  target.Name = spec.name
133
143
  end
134
144
 
145
+ --[[
146
+ A property naming an instance that does not exist YET is set aside, not
147
+ failed.
148
+
149
+ The whole subtree is assembled detached and attached at the very end --
150
+ see the last line of this function -- so while a batch is being built,
151
+ nothing in it is reachable by path. That made a one-call rig impossible:
152
+ a HingeConstraint could not point at the Attachment created two lines
153
+ above it, a Motor6D could not find its Part0, and the tool's own promise
154
+ of "build a whole model in a single call" stopped exactly where joints
155
+ began. Each of those needed a second `create` call, which is also a
156
+ second undo step.
157
+
158
+ So these are retried once the batch is attached. Anything else -- a
159
+ misspelled enum, a malformed Vector3 -- still fails immediately, because
160
+ waiting cannot make it right.
161
+ ]]
135
162
  local failures: { string } = {}
136
163
  for name, property in (spec.properties or {}) :: { [string]: PropertySpec } do
137
164
  local failure = applyProperty(target, name, property)
138
165
  if failure then
139
- table.insert(failures, failure)
166
+ if string.find(failure, Serialize.UNRESOLVED_PREFIX, 1, true) then
167
+ table.insert(deferred, {
168
+ target = target,
169
+ name = name,
170
+ spec = property,
171
+ className = tostring(spec.className),
172
+ })
173
+ else
174
+ table.insert(failures, failure)
175
+ end
140
176
  end
141
177
  end
142
178
  for _, failure in applyAttributes(target, (spec.attributes or {}) :: { [string]: any }) do
@@ -158,7 +194,7 @@ local function build(spec: { [string]: any }, parent: Instance, created: { Insta
158
194
  -- Recorded parent-first, so the response reads top-down like the Explorer.
159
195
  table.insert(created, target)
160
196
  for _, child in (spec.children or {}) :: { { [string]: any } } do
161
- build(child, target, created)
197
+ build(child, target, created, deferred)
162
198
  end
163
199
 
164
200
  target.Parent = parent
@@ -183,8 +219,52 @@ function Instances.create(params: { [string]: any }): { [string]: any }
183
219
 
184
220
  local created, recorded = Undo.record("StudioMCP.Create", "MCP create", function()
185
221
  local built: { Instance } = {}
222
+ local deferred: { Deferred } = {}
186
223
  for _, spec in specs do
187
- build(spec, Paths.resolve(spec.parent), built)
224
+ build(spec, Paths.resolve(spec.parent), built, deferred)
225
+ end
226
+
227
+ --[[
228
+ Second pass, after everything is attached.
229
+
230
+ Now every instance in the batch is reachable by path, so a reference
231
+ to a sibling resolves. A failure here is real -- the path names
232
+ something that was never going to exist -- and it fails the batch,
233
+ which cancels the recording and leaves the place untouched.
234
+ ]]
235
+ local failures: { string } = {}
236
+ for _, entry in deferred do
237
+ local failure = applyProperty(entry.target, entry.name, entry.spec)
238
+ if failure then
239
+ --[[
240
+ Stripped with find+sub, NOT gsub.
241
+
242
+ `string.gsub` takes a Lua PATTERN, and the marker is
243
+ "[unresolved] " -- which as a pattern is a character class
244
+ matching any one of u, n, r, e, s, o, l, v, d. It quietly
245
+ ate those letters out of the whole message, turning "this
246
+ property holds a BasePart" into "thiproperty holda
247
+ BasePart". Caught in QA; the message is the only thing a
248
+ reader gets here, so a mangled one is the whole failure.
249
+
250
+ The property name is already inside `failure`, so it is not
251
+ prepended a second time -- that read as "Part1: Part1: ...".
252
+ ]]
253
+ local clean = failure
254
+ local at = string.find(clean, Serialize.UNRESOLVED_PREFIX, 1, true)
255
+ if at then
256
+ clean = string.sub(clean, 1, at - 1)
257
+ .. string.sub(clean, at + #Serialize.UNRESOLVED_PREFIX)
258
+ end
259
+ table.insert(failures, string.format("%s.%s", entry.className, clean))
260
+ end
261
+ end
262
+ if #failures > 0 then
263
+ Dispatch.fail(
264
+ "BAD_PROPERTY",
265
+ string.format("Could not set %d reference(s) after the batch was built.", #failures),
266
+ table.concat(failures, "; ")
267
+ )
188
268
  end
189
269
  return built
190
270
  end)
@@ -0,0 +1,334 @@
1
+ --!strict
2
+ --[[
3
+ Spatial queries: casts, and "what is in this box".
4
+
5
+ These answer the one question the Explorer cannot: what is actually THERE.
6
+ A path tells you an instance exists and where its own pivot sits; it does not
7
+ tell you that the door frame overlaps the wall, that the spawn is buried a
8
+ stud inside the floor, or that nothing at all stands between the turret and
9
+ the player. Every one of those is a query against the world, and before this
10
+ the only way to run one was `execute_luau`.
11
+
12
+ All of it goes through a WorldRoot rather than through `workspace` directly,
13
+ for the same reason collision groups do: a `WorldModel` inside a ViewportFrame
14
+ is its own world with its own parts and its own collision groups, and a query
15
+ hard-coded to the Workspace can only ever lie about it.
16
+
17
+ The `collisionGroup` field is the point of doing this now. Roblox moved
18
+ collision group management onto WorldRoot in September 2026, and spatial
19
+ queries inside a WorldModel now honour groups the way Workspace always has.
20
+ A cast run in the wrong group reports a clear path through a wall the player
21
+ cannot walk through -- a wrong answer that looks exactly like a right one.
22
+ ]]
23
+
24
+ local Workspace = game:GetService("Workspace")
25
+
26
+ local Dispatch = require(script.Parent.Parent.Dispatch)
27
+ local Paths = require(script.Parent.Parent.Paths)
28
+ local Serialize = require(script.Parent.Parent.Serialize)
29
+
30
+ local Spatial = {}
31
+
32
+ -- A query that returns every part in a large place is not an answer, it is a
33
+ -- transcript. Callers narrow with `filter` or raise this deliberately.
34
+ local DEFAULT_LIMIT = 50
35
+ local MAX_LIMIT = 500
36
+
37
+ --[[
38
+ Which world the query runs in. Same rule as collision groups: the Workspace
39
+ unless a WorldModel is named, and anything that is not a WorldRoot is refused
40
+ by name rather than left to fail inside the engine call.
41
+ ]]
42
+ local function root(params: { [string]: any }): Instance
43
+ local path = params.worldModel
44
+ if typeof(path) ~= "string" or path == "" then
45
+ return Workspace
46
+ end
47
+
48
+ local instance = Paths.resolve(path)
49
+ if not instance:IsA("WorldRoot") then
50
+ Dispatch.fail(
51
+ "BAD_PARAMS",
52
+ string.format("%s is a %s, not a WorldModel.", path, instance.ClassName),
53
+ "Spatial queries run on a WorldRoot: the Workspace, or a WorldModel "
54
+ .. "inside a ViewportFrame. Omit `worldModel` for the Workspace."
55
+ )
56
+ end
57
+ return instance
58
+ end
59
+
60
+ local function vector(value: any, field: string): Vector3
61
+ if typeof(value) == "table" then
62
+ local list = value :: { any }
63
+ local x, y, z = tonumber(list[1]), tonumber(list[2]), tonumber(list[3])
64
+ if x and y and z then
65
+ return Vector3.new(x, y, z)
66
+ end
67
+ end
68
+ if typeof(value) == "string" then
69
+ local ok, parsed = Serialize.parse(value, "Vector3")
70
+ if ok and typeof(parsed) == "Vector3" then
71
+ return parsed
72
+ end
73
+ end
74
+ Dispatch.fail(
75
+ "BAD_PARAMS",
76
+ string.format("`%s` is not a position.", field),
77
+ 'Write it the way the Properties panel does: "12, 0, 5".'
78
+ )
79
+ error("unreachable")
80
+ end
81
+
82
+ local function optionalVector(value: any, field: string): Vector3?
83
+ if value == nil or value == "" then
84
+ return nil
85
+ end
86
+ return vector(value, field)
87
+ end
88
+
89
+ --[[
90
+ Builds the filter every query shares.
91
+
92
+ `ignore` and `only` are the same engine field with the list flipped, because
93
+ "everything except the character" and "only the doors" are both common and
94
+ only one of them is expressible at a time. Naming a path that no longer
95
+ exists is a caller mistake worth reporting: silently querying against an
96
+ empty filter gives a plausible answer to the wrong question.
97
+ ]]
98
+ local function buildParams(params: { [string]: any }): RaycastParams
99
+ local query = RaycastParams.new()
100
+
101
+ local list = params.only or params.ignore
102
+ if list ~= nil then
103
+ if typeof(list) ~= "table" then
104
+ Dispatch.fail("BAD_PARAMS", "`ignore` and `only` take a list of paths.")
105
+ end
106
+ local resolved, missing = Paths.resolveMany(list :: { string })
107
+ if #missing > 0 then
108
+ Dispatch.fail(
109
+ "NOT_FOUND",
110
+ string.format("Could not resolve: %s", table.concat(missing, ", "))
111
+ )
112
+ end
113
+ query.FilterDescendantsInstances = resolved
114
+ query.FilterType = if params.only ~= nil
115
+ then Enum.RaycastFilterType.Include
116
+ else Enum.RaycastFilterType.Exclude
117
+ end
118
+
119
+ if typeof(params.collisionGroup) == "string" and params.collisionGroup ~= "" then
120
+ query.CollisionGroup = params.collisionGroup
121
+ end
122
+ -- Off by default, matching the engine. A caller asking "can the player see
123
+ -- the exit" usually does want water ignored; one asking "what is here" does
124
+ -- not, and neither can be guessed.
125
+ query.RespectCanCollide = params.respectCanCollide == true
126
+ query.IgnoreWater = params.ignoreWater == true
127
+ query.BruteForceAllSlow = false
128
+ return query
129
+ end
130
+
131
+ local function describeHit(result: RaycastResult, origin: Vector3): { [string]: any }
132
+ return {
133
+ path = Paths.of(result.Instance),
134
+ class = result.Instance.ClassName,
135
+ position = Serialize.value(result.Position),
136
+ normal = Serialize.value(result.Normal),
137
+ material = tostring(result.Material):gsub("Enum%.Material%.", ""),
138
+ distance = math.round((result.Position - origin).Magnitude * 100) / 100,
139
+ }
140
+ end
141
+
142
+ local function describePart(part: BasePart): { [string]: any }
143
+ return {
144
+ path = Paths.of(part),
145
+ class = part.ClassName,
146
+ position = Serialize.value(part.Position),
147
+ size = Serialize.value(part.Size),
148
+ collisionGroup = part.CollisionGroup,
149
+ canCollide = part.CanCollide,
150
+ }
151
+ end
152
+
153
+ --[[
154
+ Casts a ray, a block or a sphere.
155
+
156
+ The three are one operation with one extra argument, and the engine treats
157
+ them that way too -- `Blockcast` and `Spherecast` take the same params and
158
+ return the same RaycastResult. Splitting them into three handlers would only
159
+ mean three copies of the filter code.
160
+
161
+ A miss is reported as `hit = false` with the distance travelled, not as an
162
+ error and not as an empty object. "Nothing is in the way" is a real answer
163
+ and usually the one being checked for.
164
+ ]]
165
+ function Spatial.cast(params: { [string]: any }): { [string]: any }
166
+ local world = root(params) :: WorldRoot
167
+ local shape = tostring(params.shape or "ray")
168
+
169
+ local origin = vector(params.from, "from")
170
+
171
+ --[[
172
+ Direction is given either as a target point or as a vector, because both
173
+ are natural and they are not interchangeable. "Can the turret see the
174
+ player" is two positions; "is there ground below" is a direction and a
175
+ distance. Taking only one of them forces the caller to do vector maths
176
+ in the prompt, which is exactly where an off-by-one sign lives.
177
+ ]]
178
+ local direction: Vector3
179
+ local to = optionalVector(params.to, "to")
180
+ if to then
181
+ direction = to - origin
182
+ else
183
+ local towards = optionalVector(params.direction, "direction")
184
+ if not towards then
185
+ Dispatch.fail(
186
+ "BAD_PARAMS",
187
+ "A cast needs somewhere to go.",
188
+ "Give `to` for a point to aim at, or `direction` plus `distance`."
189
+ )
190
+ error("unreachable")
191
+ end
192
+ local distance = tonumber(params.distance) or 100
193
+ direction = towards.Unit * distance
194
+ end
195
+
196
+ local query = buildParams(params)
197
+ local result: RaycastResult?
198
+
199
+ if shape == "ray" then
200
+ result = world:Raycast(origin, direction, query)
201
+ elseif shape == "block" then
202
+ local size = optionalVector(params.size, "size") or Vector3.new(1, 1, 1)
203
+ result = world:Blockcast(CFrame.new(origin), size, direction, query)
204
+ elseif shape == "sphere" then
205
+ local radius = tonumber(params.radius) or 1
206
+ result = world:Spherecast(origin, radius, direction, query)
207
+ else
208
+ Dispatch.fail(
209
+ "BAD_PARAMS",
210
+ string.format("unknown cast shape %q", shape),
211
+ 'Use "ray", "block" or "sphere".'
212
+ )
213
+ end
214
+
215
+ if not result then
216
+ return {
217
+ hit = false,
218
+ shape = shape,
219
+ from = Serialize.value(origin),
220
+ travelled = math.round(direction.Magnitude * 100) / 100,
221
+ world = Paths.of(world),
222
+ note = "Nothing was in the way for the whole length of the cast.",
223
+ }
224
+ end
225
+
226
+ local hit = describeHit(result, origin)
227
+ hit.hit = true
228
+ hit.shape = shape
229
+ hit.from = Serialize.value(origin)
230
+ hit.world = Paths.of(world)
231
+ return hit
232
+ end
233
+
234
+ --[[
235
+ Everything inside a volume.
236
+
237
+ `part` is the one worth reaching for on a build: it takes an existing part
238
+ and reports what overlaps it, which answers "is this door clipping the wall"
239
+ directly rather than by reconstructing the door's bounds by hand.
240
+ ]]
241
+ function Spatial.overlap(params: { [string]: any }): { [string]: any }
242
+ local world = root(params) :: WorldRoot
243
+ local region = tostring(params.region or "box")
244
+ local limit = math.clamp(tonumber(params.limit) or DEFAULT_LIMIT, 1, MAX_LIMIT)
245
+
246
+ local overlap = OverlapParams.new()
247
+ local query = buildParams(params)
248
+ overlap.FilterDescendantsInstances = query.FilterDescendantsInstances
249
+ overlap.FilterType = query.FilterType
250
+ overlap.CollisionGroup = query.CollisionGroup
251
+ overlap.RespectCanCollide = query.RespectCanCollide
252
+ -- Asked for one over the limit so the response can say whether the list was
253
+ -- cut, instead of a caller reading exactly 50 as "there are 50".
254
+ overlap.MaxParts = limit + 1
255
+
256
+ local found: { BasePart }
257
+ local about: { [string]: any } = {}
258
+
259
+ if region == "box" then
260
+ local centre = vector(params.at, "at")
261
+ local size = optionalVector(params.size, "size") or Vector3.new(4, 4, 4)
262
+ found = world:GetPartBoundsInBox(CFrame.new(centre), size, overlap)
263
+ about.at, about.size = Serialize.value(centre), Serialize.value(size)
264
+ elseif region == "radius" then
265
+ local centre = vector(params.at, "at")
266
+ local radius = tonumber(params.radius) or 4
267
+ found = world:GetPartBoundsInRadius(centre, radius, overlap)
268
+ about.at, about.radius = Serialize.value(centre), radius
269
+ elseif region == "part" then
270
+ local path = params.path
271
+ if typeof(path) ~= "string" or path == "" then
272
+ Dispatch.fail("BAD_PARAMS", 'region="part" needs `path`, the part to test.')
273
+ end
274
+ local instance = Paths.resolve(path)
275
+ if not instance:IsA("BasePart") then
276
+ Dispatch.fail(
277
+ "BAD_PARAMS",
278
+ string.format("%s is a %s, not a part.", path, instance.ClassName)
279
+ )
280
+ end
281
+ local subject = instance :: BasePart
282
+ --[[
283
+ The subject always overlaps itself, and a caller who asked what
284
+ touches a door does not mean the door. Added to the filter rather
285
+ than stripped from the results, so it does not eat one of the slots
286
+ the limit allows.
287
+ ]]
288
+ if overlap.FilterType == Enum.RaycastFilterType.Exclude then
289
+ local excluded = overlap.FilterDescendantsInstances
290
+ table.insert(excluded, subject)
291
+ overlap.FilterDescendantsInstances = excluded
292
+ end
293
+ found = world:GetPartsInPart(subject, overlap)
294
+ about.path = Paths.of(subject)
295
+ else
296
+ Dispatch.fail(
297
+ "BAD_PARAMS",
298
+ string.format("unknown region %q", region),
299
+ 'Use "box", "radius" or "part".'
300
+ )
301
+ error("unreachable")
302
+ end
303
+
304
+ local truncated = #found > limit
305
+ local items: { { [string]: any } } = {}
306
+ for index, part in found do
307
+ if index > limit then
308
+ break
309
+ end
310
+ table.insert(items, describePart(part))
311
+ end
312
+
313
+ about.region = region
314
+ about.items = items
315
+ about.count = #items
316
+ about.truncated = truncated
317
+ about.world = Paths.of(world)
318
+ if truncated then
319
+ about.note = string.format(
320
+ "More than %d parts are in this volume; raise `limit` or narrow with `only`.",
321
+ limit
322
+ )
323
+ end
324
+ return about
325
+ end
326
+
327
+ function Spatial.register()
328
+ Dispatch.registerAll("spatial", {
329
+ cast = Spatial.cast,
330
+ overlap = Spatial.overlap,
331
+ })
332
+ end
333
+
334
+ return Spatial