@el4cteo/rbx-studio-mcp 0.4.2 → 0.4.6

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 (38) hide show
  1. package/README.md +222 -211
  2. package/dist/bridge/api.js +3 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +13 -0
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/remote.js +15 -1
  7. package/dist/bridge/remote.js.map +1 -1
  8. package/dist/bridge/rpc.js +35 -6
  9. package/dist/bridge/rpc.js.map +1 -1
  10. package/dist/bridge/server.js +55 -7
  11. package/dist/bridge/server.js.map +1 -1
  12. package/dist/doctor.js +160 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/index.js +29 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/protocol.js.map +1 -1
  17. package/dist/tools/exec.js +53 -3
  18. package/dist/tools/exec.js.map +1 -1
  19. package/dist/tools/generate.js +120 -0
  20. package/dist/tools/generate.js.map +1 -0
  21. package/dist/tools/scripts.js +20 -1
  22. package/dist/tools/scripts.js.map +1 -1
  23. package/dist/tools/world.js +203 -20
  24. package/dist/tools/world.js.map +1 -1
  25. package/package.json +2 -1
  26. package/plugin/src/Config.luau +1 -1
  27. package/plugin/src/Console.luau +233 -9
  28. package/plugin/src/Transport.luau +6 -1
  29. package/plugin/src/Visuals.luau +52 -1
  30. package/plugin/src/handlers/Assets.luau +352 -145
  31. package/plugin/src/handlers/Generate.luau +386 -0
  32. package/plugin/src/handlers/Geometry.luau +170 -0
  33. package/plugin/src/handlers/Scripts.luau +79 -1
  34. package/plugin/src/handlers/Viewport.luau +416 -302
  35. package/plugin/src/handlers/World.luau +15 -5
  36. package/plugin/src/init.server.luau +725 -674
  37. package/scripts/test-bridge.mjs +56 -0
  38. package/scripts/test-failover.mjs +136 -95
@@ -1,145 +1,352 @@
1
- --!strict
2
- --[[
3
- Inserting Creator Store assets into the place.
4
-
5
- The search half of this lives on the server, not here: Node already has
6
- internet access and Roblox's toolbox endpoints answer unauthenticated, while
7
- a plugin making outbound HTTP needs the user to grant permission per domain.
8
- Doing it server-side means search works the moment the server starts, and the
9
- plugin only ever talks to the one host it already talks to.
10
-
11
- So this file does the half only Studio can do: turn an asset id into real
12
- instances in the data model, under a recording so one insert is one Ctrl+Z.
13
- ]]
14
-
15
- local Dispatch = require(script.Parent.Parent.Dispatch)
16
- local Paths = require(script.Parent.Parent.Paths)
17
- local Undo = require(script.Parent.Parent.Undo)
18
-
19
- local Assets = {}
20
-
21
- --[[
22
- Counts scripts anywhere inside what was inserted.
23
-
24
- Free models carrying scripts are the oldest hazard on the platform, and the
25
- agent inserting one has no other way to know. This is reported on every
26
- insert rather than only when asked, because the caller who most needs to see
27
- it is the one who did not think to look.
28
- ]]
29
- local function countScripts(root: Instance): (number, { string })
30
- local total = 0
31
- local names: { string } = {}
32
- for _, descendant in root:GetDescendants() do
33
- if descendant:IsA("LuaSourceContainer") then
34
- total += 1
35
- if #names < 10 then
36
- table.insert(names, string.format("%s (%s)", descendant.Name, descendant.ClassName))
37
- end
38
- end
39
- end
40
- return total, names
41
- end
42
-
43
- function Assets.insert(params: { [string]: any }): { [string]: any }
44
- local assetId = tonumber(params.assetId)
45
- if assetId == nil or assetId <= 0 then
46
- Dispatch.fail("BAD_PARAMS", "insert needs a numeric `assetId`.")
47
- end
48
-
49
- local parent = if typeof(params.parent) == "string" and params.parent ~= ""
50
- then Paths.resolve(params.parent)
51
- else workspace
52
-
53
- --[[
54
- `game:GetObjects`, not `InsertService:LoadAsset`.
55
-
56
- LoadAsset is the documented route and it refuses everything here: every
57
- public model tried, from several creators, came back "User is not
58
- authorized to access Asset". It enforces ownership, which makes it useful
59
- for a game loading its own assets and useless for inserting from the
60
- Creator Store -- the entire point of this tool.
61
-
62
- GetObjects is the plugin-security path Studio's own toolbox uses, and it
63
- loaded the same ids without complaint. It returns an array of top-level
64
- instances rather than a wrapper model, so there is nothing to unwrap.
65
- ]]
66
- local ok, loaded = pcall(function()
67
- return game:GetObjects("rbxassetid://" .. assetId)
68
- end)
69
- if not ok then
70
- Dispatch.fail(
71
- "INSERT_FAILED",
72
- string.format("Could not load asset %d: %s", assetId, tostring(loaded)),
73
- "The asset may be private, deleted, or not a model."
74
- )
75
- end
76
-
77
- local children = loaded :: { Instance }
78
- if #children == 0 then
79
- Dispatch.fail("EMPTY_ASSET", string.format("Asset %d contained nothing.", assetId))
80
- end
81
-
82
- -- Counted across every root, since an asset can arrive as several instances
83
- -- and a script hiding in the second one counts exactly as much.
84
- local scriptCount = 0
85
- local scriptNames: { string } = {}
86
- for _, child in children do
87
- local count, names = countScripts(child)
88
- scriptCount += count
89
- for _, name in names do
90
- if #scriptNames < 10 then
91
- table.insert(scriptNames, name)
92
- end
93
- end
94
- if child:IsA("LuaSourceContainer") then
95
- scriptCount += 1
96
- if #scriptNames < 10 then
97
- table.insert(scriptNames, string.format("%s (%s)", child.Name, child.ClassName))
98
- end
99
- end
100
- end
101
-
102
- local inserted: { string } = {}
103
- local _, undoable = Undo.record("MCPInsertAsset", "MCP insert asset", function()
104
- for _, child in children do
105
- if typeof(params.name) == "string" and params.name ~= "" and #children == 1 then
106
- child.Name = params.name
107
- end
108
- child.Parent = parent
109
- table.insert(inserted, Paths.of(child))
110
- end
111
-
112
- -- Position after parenting, so PrimaryPart and pivot are settled.
113
- if typeof(params.position) == "string" and params.position ~= "" then
114
- local x, y, z = string.match(params.position, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
115
- if x then
116
- local target = CFrame.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
117
- for _, child in children do
118
- pcall(function()
119
- if child:IsA("Model") then
120
- (child :: Model):PivotTo(target)
121
- elseif child:IsA("BasePart") then
122
- (child :: BasePart).CFrame = target
123
- end
124
- end)
125
- end
126
- end
127
- end
128
- end)
129
-
130
- return {
131
- inserted = inserted,
132
- assetId = assetId,
133
- scriptCount = scriptCount,
134
- scripts = if scriptCount > 0 then scriptNames else nil,
135
- undoable = undoable,
136
- }
137
- end
138
-
139
- function Assets.register()
140
- Dispatch.registerAll("assets", {
141
- insert = Assets.insert,
142
- })
143
- end
144
-
145
- return Assets
1
+ --!strict
2
+ --[[
3
+ Inserting Creator Store assets into the place.
4
+
5
+ The search half of this lives on the server, not here: Node already has
6
+ internet access and Roblox's toolbox endpoints answer unauthenticated, while
7
+ a plugin making outbound HTTP needs the user to grant permission per domain.
8
+ Doing it server-side means search works the moment the server starts, and the
9
+ plugin only ever talks to the one host it already talks to.
10
+
11
+ So this file does the half only Studio can do: turn an asset id into real
12
+ instances in the data model, under a recording so one insert is one Ctrl+Z.
13
+ ]]
14
+
15
+ local AssetService = game:GetService("AssetService")
16
+
17
+ local Dispatch = require(script.Parent.Parent.Dispatch)
18
+ local Paths = require(script.Parent.Parent.Paths)
19
+ local Undo = require(script.Parent.Parent.Undo)
20
+
21
+ local Assets = {}
22
+
23
+ --[[
24
+ Counts scripts anywhere inside what was inserted.
25
+
26
+ Free models carrying scripts are the oldest hazard on the platform, and the
27
+ agent inserting one has no other way to know. This is reported on every
28
+ insert rather than only when asked, because the caller who most needs to see
29
+ it is the one who did not think to look.
30
+ ]]
31
+ local function countScripts(root: Instance): (number, { string })
32
+ local total = 0
33
+ local names: { string } = {}
34
+ for _, descendant in root:GetDescendants() do
35
+ if descendant:IsA("LuaSourceContainer") then
36
+ total += 1
37
+ if #names < 10 then
38
+ table.insert(names, string.format("%s (%s)", descendant.Name, descendant.ClassName))
39
+ end
40
+ end
41
+ end
42
+ return total, names
43
+ end
44
+
45
+ function Assets.insert(params: { [string]: any }): { [string]: any }
46
+ local assetId = tonumber(params.assetId)
47
+ if assetId == nil or assetId <= 0 then
48
+ Dispatch.fail("BAD_PARAMS", "insert needs a numeric `assetId`.")
49
+ end
50
+
51
+ local parent = if typeof(params.parent) == "string" and params.parent ~= ""
52
+ then Paths.resolve(params.parent)
53
+ else workspace
54
+
55
+ --[[
56
+ `game:GetObjects`, not `InsertService:LoadAsset`.
57
+
58
+ LoadAsset is the documented route and it refuses everything here: every
59
+ public model tried, from several creators, came back "User is not
60
+ authorized to access Asset". It enforces ownership, which makes it useful
61
+ for a game loading its own assets and useless for inserting from the
62
+ Creator Store -- the entire point of this tool.
63
+
64
+ GetObjects is the plugin-security path Studio's own toolbox uses, and it
65
+ loaded the same ids without complaint. It returns an array of top-level
66
+ instances rather than a wrapper model, so there is nothing to unwrap.
67
+ ]]
68
+ local ok, loaded = pcall(function()
69
+ return game:GetObjects("rbxassetid://" .. assetId)
70
+ end)
71
+ if not ok then
72
+ Dispatch.fail(
73
+ "INSERT_FAILED",
74
+ string.format("Could not load asset %d: %s", assetId, tostring(loaded)),
75
+ "The asset may be private, deleted, or not a model."
76
+ )
77
+ end
78
+
79
+ local children = loaded :: { Instance }
80
+ if #children == 0 then
81
+ Dispatch.fail("EMPTY_ASSET", string.format("Asset %d contained nothing.", assetId))
82
+ end
83
+
84
+ -- Counted across every root, since an asset can arrive as several instances
85
+ -- and a script hiding in the second one counts exactly as much.
86
+ local scriptCount = 0
87
+ local scriptNames: { string } = {}
88
+ for _, child in children do
89
+ local count, names = countScripts(child)
90
+ scriptCount += count
91
+ for _, name in names do
92
+ if #scriptNames < 10 then
93
+ table.insert(scriptNames, name)
94
+ end
95
+ end
96
+ if child:IsA("LuaSourceContainer") then
97
+ scriptCount += 1
98
+ if #scriptNames < 10 then
99
+ table.insert(scriptNames, string.format("%s (%s)", child.Name, child.ClassName))
100
+ end
101
+ end
102
+ end
103
+
104
+ local inserted: { string } = {}
105
+ local _, undoable = Undo.record("MCPInsertAsset", "MCP insert asset", function()
106
+ for _, child in children do
107
+ if typeof(params.name) == "string" and params.name ~= "" and #children == 1 then
108
+ child.Name = params.name
109
+ end
110
+ child.Parent = parent
111
+ table.insert(inserted, Paths.of(child))
112
+ end
113
+
114
+ -- Position after parenting, so PrimaryPart and pivot are settled.
115
+ if typeof(params.position) == "string" and params.position ~= "" then
116
+ local x, y, z = string.match(params.position, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
117
+ if x then
118
+ local target = CFrame.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
119
+ for _, child in children do
120
+ pcall(function()
121
+ if child:IsA("Model") then
122
+ (child :: Model):PivotTo(target)
123
+ elseif child:IsA("BasePart") then
124
+ (child :: BasePart).CFrame = target
125
+ end
126
+ end)
127
+ end
128
+ end
129
+ end
130
+ end)
131
+
132
+ return {
133
+ inserted = inserted,
134
+ assetId = assetId,
135
+ scriptCount = scriptCount,
136
+ scripts = if scriptCount > 0 then scriptNames else nil,
137
+ undoable = undoable,
138
+ }
139
+ end
140
+
141
+ --[[
142
+ Turns editable mesh and image data into content that replicates.
143
+
144
+ Anything built in memory -- a generated mesh, a procedurally written image --
145
+ is backed by an editable object, and editable objects do not replicate. In
146
+ edit mode nothing shows this: the part looks correct and reads correct. Press
147
+ Play and the server has it while every client renders a cyan-and-magenta
148
+ checkerboard, which is the engine's placeholder for content it could not
149
+ send.
150
+
151
+ `CreateDataModelContentAsync` is the fix. It bakes the data into static
152
+ content that lives in this DataModel session and replicates normally. It does
153
+ not upload anything and does not touch the user's account -- the result is
154
+ local to the place, and still has to be saved with it.
155
+
156
+ Reported per part rather than failing as a batch: one mesh the service
157
+ refuses should not cost the twenty that converted, and a part that needed
158
+ nothing doing should say so rather than look like a failure.
159
+ ]]
160
+ function Assets.bake(params: { [string]: any }): { [string]: any }
161
+ local paths = params.paths
162
+ if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
163
+ Dispatch.fail("BAD_PARAMS", "bake needs a `paths` list.")
164
+ end
165
+
166
+ local targets: { MeshPart } = {}
167
+ for _, path in paths :: { string } do
168
+ local instance = Paths.resolve(path)
169
+ if instance:IsA("MeshPart") then
170
+ table.insert(targets, instance :: MeshPart)
171
+ else
172
+ -- A model is the natural thing to point at after a generation, so
173
+ -- it is walked rather than refused.
174
+ for _, descendant in instance:GetDescendants() do
175
+ if descendant:IsA("MeshPart") then
176
+ table.insert(targets, descendant :: MeshPart)
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ if #targets == 0 then
183
+ Dispatch.fail(
184
+ "NOTHING_TO_BAKE",
185
+ "None of those paths held a MeshPart.",
186
+ "Point at a MeshPart, or at a model containing some."
187
+ )
188
+ end
189
+
190
+ --[[
191
+ What kind of content this is, which decides whether baking applies.
192
+
193
+ `Object` is an EditableMesh or EditableImage and is the only thing the
194
+ service takes. `Uri` is a published asset and already replicates. `None`
195
+ is nothing at all.
196
+
197
+ `Opaque` is the interesting one, and the reason this is a three-way
198
+ answer rather than a boolean. It reads like "in memory, so bake it" and
199
+ the service refuses it: "The input content type is not supported by
200
+ AssetService::CreateDataModelContentAsync yet!". Generated meshes are
201
+ Opaque, so pointing this at a generation used to produce that engine
202
+ message once per property per part -- four identical lines for one
203
+ chest, none of which said what to do instead. Reported as its own case
204
+ now, once, with the actual answer.
205
+ ]]
206
+ local function kindOf(content: any): string
207
+ local ok, source = pcall(function()
208
+ return tostring((content :: any).SourceType)
209
+ end)
210
+ if not ok then
211
+ return "none"
212
+ end
213
+ if string.find(source, "Object") then
214
+ return "editable"
215
+ elseif string.find(source, "Opaque") then
216
+ return "opaque"
217
+ end
218
+ return "none"
219
+ end
220
+
221
+ local converted: { string } = {}
222
+ local skipped: { string } = {}
223
+ local failed: { string } = {}
224
+ local opaque = 0
225
+
226
+ --[[
227
+ Bakes one content value, or says why it could not.
228
+
229
+ `CreateDataModelContentAsync` returns a TUPLE -- a
230
+ `Enum.CreateContentResult` and then the content -- not the content
231
+ alone. Reading the first value as the result is the mistake this
232
+ function exists to stop repeating: it produced
233
+ `Enum.CreateContentResult.Success` where a Content was wanted, and
234
+ assigning an EnumItem to a content property fails, quietly, inside the
235
+ pcall that was meant to guard the property not existing.
236
+ ]]
237
+ local function bakeContent(current: any): (any, string?)
238
+ local ok, status, content = pcall(function()
239
+ return AssetService:CreateDataModelContentAsync(current)
240
+ end)
241
+ if not ok then
242
+ return nil, tostring(status)
243
+ end
244
+ if status ~= Enum.CreateContentResult.Success then
245
+ return nil, tostring(status)
246
+ end
247
+ return content, nil
248
+ end
249
+
250
+ local _, undoable = Undo.record("MCPBake", "MCP bake content", function()
251
+ for _, part in targets do
252
+ local did = false
253
+ local sawOpaque = false
254
+ local path = Paths.of(part)
255
+
256
+ --[[
257
+ The mesh goes on through `ApplyMesh`, not by assignment.
258
+
259
+ `MeshContent` cannot be written from a plugin thread at all --
260
+ "The current thread cannot write 'MeshContent' (lacking
261
+ capability NotAccessible)". Measured. The supported route is to
262
+ build a MeshPart around the baked content and apply it, which
263
+ the engine allows.
264
+
265
+ `ApplyMesh` takes the donor's size with it, so the part would
266
+ silently resize on what is meant to be an invisible conversion.
267
+ The size is put back.
268
+ ]]
269
+ local mesh = (part :: any).MeshContent
270
+ local meshKind = if mesh == nil then "none" else kindOf(mesh)
271
+ if meshKind == "editable" then
272
+ local baked, why = bakeContent(mesh)
273
+ if baked == nil then
274
+ table.insert(failed, string.format("%s (mesh: %s)", path, tostring(why)))
275
+ else
276
+ local okNew, fresh = pcall(function()
277
+ return AssetService:CreateMeshPartAsync(baked, {
278
+ CollisionFidelity = part.CollisionFidelity,
279
+ })
280
+ end)
281
+ if not okNew then
282
+ table.insert(failed, string.format("%s (mesh: %s)", path, tostring(fresh)))
283
+ else
284
+ local size = part.Size
285
+ local okApply, applyError = pcall(function()
286
+ part:ApplyMesh(fresh :: MeshPart)
287
+ end)
288
+ if okApply then
289
+ part.Size = size
290
+ did = true
291
+ else
292
+ table.insert(failed, string.format("%s (mesh: %s)", path, tostring(applyError)))
293
+ end
294
+ end
295
+ end
296
+ elseif meshKind == "opaque" then
297
+ sawOpaque = true
298
+ end
299
+
300
+ -- The texture is an ordinary property write, which this thread may do.
301
+ local texture = (part :: any).TextureContent
302
+ local textureKind = if texture == nil then "none" else kindOf(texture)
303
+ if textureKind == "editable" then
304
+ local baked, why = bakeContent(texture)
305
+ if baked == nil then
306
+ table.insert(failed, string.format("%s (texture: %s)", path, tostring(why)))
307
+ else
308
+ local okSet, setError = pcall(function()
309
+ (part :: any).TextureContent = baked
310
+ end)
311
+ if okSet then
312
+ did = true
313
+ else
314
+ -- Reported, not swallowed. A failed write used to leave
315
+ -- `did` false and fall through to "skipped", so a part
316
+ -- this tool had failed to convert was announced as one
317
+ -- that needed nothing doing.
318
+ table.insert(failed, string.format("%s (texture: %s)", path, tostring(setError)))
319
+ end
320
+ end
321
+ elseif textureKind == "opaque" then
322
+ sawOpaque = true
323
+ end
324
+
325
+ if did then
326
+ table.insert(converted, path)
327
+ elseif sawOpaque then
328
+ opaque += 1
329
+ else
330
+ table.insert(skipped, path)
331
+ end
332
+ end
333
+ end)
334
+
335
+ return {
336
+ converted = converted,
337
+ skipped = skipped,
338
+ failed = failed,
339
+ opaque = opaque,
340
+ examined = #targets,
341
+ undoable = undoable,
342
+ }
343
+ end
344
+
345
+ function Assets.register()
346
+ Dispatch.registerAll("assets", {
347
+ insert = Assets.insert,
348
+ bake = Assets.bake,
349
+ })
350
+ end
351
+
352
+ return Assets