@el4cteo/rbx-studio-mcp 0.6.1 → 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 (75) 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 +8 -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/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -1,5 +1,9 @@
1
1
  import { z } from "zod";
2
+ import { grantAssets, publishPlace, uploadAsset } from "../lib/cloudassets.js";
3
+ import { ToolError } from "../lib/errors.js";
2
4
  import { json, table, text, textOf } from "../lib/format.js";
5
+ import { assetQuotas, restartServers } from "../lib/liveops.js";
6
+ import { requireCredentials, requirePlace, requireUniverse } from "../lib/opencloud.js";
3
7
  import { defineTool } from "../lib/tool.js";
4
8
  /** Roblox's toolbox search. Public, unauthenticated, and the same index Studio's own asset browser uses. */
5
9
  /** Segmentation runs the same slow generation backend `generate` does. */
@@ -15,28 +19,96 @@ const CATEGORIES = { model: 10, decal: 13, mesh: 40, audio: 3 };
15
19
  * while a plugin making outbound HTTP needs the user to approve each domain in
16
20
  * Plugin Management. Searching here means it works the moment the server starts.
17
21
  */
18
- async function searchCreatorStore(keyword, category, limit) {
22
+ /**
23
+ * Pages to pull before filtering gives up looking for more.
24
+ *
25
+ * Filtering happens here rather than at Roblox (see below), so a strict filter
26
+ * over one page of thirty can return nothing while the good results sit on page
27
+ * two. Four pages is enough to find script-free models for any ordinary search
28
+ * without turning one call into a crawl of the whole index.
29
+ */
30
+ const MAX_PAGES = 4;
31
+ /**
32
+ * Searches the Creator Store from the server rather than the plugin.
33
+ *
34
+ * Node already has internet access and these endpoints answer unauthenticated,
35
+ * while a plugin making outbound HTTP needs the user to approve each domain in
36
+ * Plugin Management. Searching here means it works the moment the server starts.
37
+ *
38
+ * Filtering and ranking are done HERE, on purpose. The endpoint accepts
39
+ * `sortType` and `creatorFilter` and ignores both — measured, Relevance,
40
+ * MostTaken, Favorited and Updated returned byte-identical results for the same
41
+ * keyword, as did a Roblox-only creator filter. Passing them through would have
42
+ * looked like sorting and done nothing, which is worse than not offering it. So
43
+ * the only server-side lever that works is the cursor, and everything else is
44
+ * decided from the details each result carries.
45
+ */
46
+ async function searchCreatorStore(keyword, category, limit, filters = {}) {
19
47
  const categoryId = CATEGORIES[category] ?? 10;
20
- const url = `${TOOLBOX_SEARCH}/${categoryId}?keyword=${encodeURIComponent(keyword)}` +
21
- `&limit=${Math.min(limit, 30)}&sortType=Relevance`;
22
- const found = await fetch(url, { signal: AbortSignal.timeout(15_000) });
23
- if (!found.ok) {
24
- throw new Error(`Creator Store search failed (${found.status}). Roblox may be rate-limiting.`);
25
- }
26
- const results = (await found.json());
27
- const ids = (results.data ?? []).map((entry) => entry.id).slice(0, limit);
28
- if (ids.length === 0)
29
- return [];
30
- // Search returns bare ids; everything worth showing — name, creator, whether
31
- // it carries scripts — needs the second call.
32
- const detailed = await fetch(`${TOOLBOX_DETAILS}?assetIds=${ids.join(",")}`, {
33
- signal: AbortSignal.timeout(15_000),
34
- });
35
- if (!detailed.ok) {
36
- throw new Error(`Could not read asset details (${detailed.status}).`);
48
+ const kept = [];
49
+ let cursor = "";
50
+ let scanned = 0;
51
+ let total;
52
+ for (let page = 0; page < MAX_PAGES && kept.length < limit; page += 1) {
53
+ const url = `${TOOLBOX_SEARCH}/${categoryId}?keyword=${encodeURIComponent(keyword)}` +
54
+ `&limit=30${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
55
+ const found = await fetch(url, { signal: AbortSignal.timeout(15_000) });
56
+ if (!found.ok) {
57
+ throw new Error(`Creator Store search failed (${found.status}). Roblox may be rate-limiting.`);
58
+ }
59
+ const results = (await found.json());
60
+ total = total ?? results.totalResults;
61
+ const ids = (results.data ?? []).map((entry) => entry.id);
62
+ if (ids.length === 0)
63
+ break;
64
+ scanned += ids.length;
65
+ // Search returns bare ids; everything worth showing — name, creator, script
66
+ // count, price — needs the second call.
67
+ const detailed = await fetch(`${TOOLBOX_DETAILS}?assetIds=${ids.join(",")}`, {
68
+ signal: AbortSignal.timeout(15_000),
69
+ });
70
+ if (!detailed.ok) {
71
+ throw new Error(`Could not read asset details (${detailed.status}).`);
72
+ }
73
+ const payload = (await detailed.json());
74
+ for (const entry of payload.data ?? []) {
75
+ if (filters.excludeScripts && entry.asset?.hasScripts)
76
+ continue;
77
+ if (filters.verifiedOnly && !entry.creator?.isVerifiedCreator)
78
+ continue;
79
+ if (filters.freeOnly && entry.fiatProduct?.isFree === false)
80
+ continue;
81
+ if (filters.minVotes !== undefined && (entry.voting?.voteCount ?? 0) < filters.minVotes) {
82
+ continue;
83
+ }
84
+ if (filters.maxTriangles !== undefined) {
85
+ const triangles = entry.asset?.modelTechnicalDetails?.objectMeshSummary?.triangles;
86
+ if (triangles !== undefined && triangles > filters.maxTriangles)
87
+ continue;
88
+ }
89
+ kept.push(entry);
90
+ }
91
+ cursor = results.nextPageCursor ?? "";
92
+ if (!cursor)
93
+ break;
37
94
  }
38
- const payload = (await detailed.json());
39
- return payload.data ?? [];
95
+ /*
96
+ * Ranked by approval WEIGHTED BY how many people voted.
97
+ *
98
+ * The raw percentage is what the API gives and it is close to meaningless on
99
+ * its own: 82% of 5000 votes and 100% of 2 votes sort the wrong way round
100
+ * every time, and the second is the one nobody should be inserting into their
101
+ * game. Pulling the percentage toward 50 in proportion to how little evidence
102
+ * there is behind it costs nothing and puts the well-used models first.
103
+ */
104
+ const score = (entry) => {
105
+ const percent = entry.voting?.upVotePercent ?? 50;
106
+ const votes = entry.voting?.voteCount ?? 0;
107
+ const confidence = votes / (votes + 50);
108
+ return 50 + (percent - 50) * confidence;
109
+ };
110
+ kept.sort((a, b) => score(b) - score(a));
111
+ return { items: kept.slice(0, limit), scanned, total };
40
112
  }
41
113
  export function registerWorldTools(context) {
42
114
  const { bridge } = context;
@@ -70,15 +142,44 @@ export function registerWorldTools(context) {
70
142
  "Roblox returns bare grey MeshParts, so a brick wall with a hole cut in " +
71
143
  "it would otherwise come back as a grey slab - correct geometry that " +
72
144
  "looks like a mistake.\n\n" +
145
+ "`mesh` reads the real triangle and vertex counts of MeshParts, which is " +
146
+ "the only way to tell a 40,000-triangle tree from a 400-triangle one — " +
147
+ "they are identical in the Explorer and in Properties, and the difference " +
148
+ "is whether the place runs on a phone. It also reports mesh size against " +
149
+ "part size: the same triangles stretched over a bigger object is the usual " +
150
+ "reason a model costs more than it looks like it should.\n\n" +
151
+ "`mesh` only works on meshes the signed-in Studio user or the experience " +
152
+ "owner OWNS. Roblox refuses to open anyone else's, so a model inserted " +
153
+ "from the Creator Store cannot be measured this way — the tool says which " +
154
+ "parts were skipped rather than failing the whole batch.\n\n" +
155
+ "`mirror` flips instances across a plane and has no engine API behind " +
156
+ "it — Studio simply cannot do this, which is why people ask for it. " +
157
+ "Mirroring about the middle of the selection is the default, because " +
158
+ "mirroring a building at x=200 about the world origin puts it 400 " +
159
+ "studs away rather than flipping it in place. It COPIES by default; " +
160
+ "pass `copy: false` to flip the originals. MeshParts move and rotate " +
161
+ "correctly but their meshes are not remade, so an asymmetric mesh " +
162
+ "still reads the same way round.\n\n" +
73
163
  "`segment` runs Roblox's Cube model and takes tens of seconds; the rest " +
74
164
  "are fast. Each call is one undo step.",
75
165
  inputSchema: {
76
166
  op: z
77
- .enum(["union", "subtract", "intersect", "fragment", "sweep", "segment"])
167
+ .enum(["union", "subtract", "intersect", "fragment", "sweep", "segment", "mesh", "mirror"])
78
168
  .describe("'union' merges, 'subtract' cuts `with` out of `path`, 'intersect' " +
79
169
  "keeps only the overlap, 'fragment' shatters into debris, 'sweep' " +
80
170
  "builds a motion volume, 'segment' cuts a mesh into named parts."),
81
171
  path: z.string().describe("The part being operated on - the one cut from, for subtract."),
172
+ about: z
173
+ .string()
174
+ .optional()
175
+ .describe('mirror only: the plane position, e.g. "0, 0, 0". Defaults to ' +
176
+ "the middle of what is being mirrored, which flips it in place."),
177
+ copy: z
178
+ .boolean()
179
+ .optional()
180
+ .describe("mirror only: leave the originals and add mirrored copies. True by " +
181
+ "default — that is what builds a symmetrical structure from half " +
182
+ "of one. False flips the originals in place."),
82
183
  with: z
83
184
  .array(z.string())
84
185
  .max(50)
@@ -117,7 +218,9 @@ export function registerWorldTools(context) {
117
218
  axis: z
118
219
  .string()
119
220
  .optional()
120
- .describe('sweep only: axis to spin around, e.g. "0, 1, 0". Defaults to up.'),
221
+ .describe('sweep: axis to spin around, e.g. "0, 1, 0" (defaults to up). ' +
222
+ 'mirror: which axis to flip across — "X", "Y" or "Z", ' +
223
+ "defaulting to X."),
121
224
  pivot: z
122
225
  .string()
123
226
  .optional()
@@ -176,10 +279,41 @@ export function registerWorldTools(context) {
176
279
  .boolean()
177
280
  .default(false)
178
281
  .describe("Return disconnected chunks as separate parts rather than one."),
282
+ paths: z
283
+ .array(z.string())
284
+ .max(50)
285
+ .optional()
286
+ .describe("mesh only: the MeshParts to read geometry from."),
179
287
  studioId: z.string().optional().describe("Target Studio; omit for the active one."),
180
288
  },
181
289
  destructive: true,
182
290
  }, async (args) => {
291
+ if (args.op === "mirror") {
292
+ const paths = args.paths ?? (args.path !== undefined ? [args.path] : []);
293
+ if (paths.length === 0) {
294
+ return text("mirror needs `paths` — the instances to flip.");
295
+ }
296
+ return json(await bridge.call("geometry.mirror", { paths, axis: args.axis, about: args.about, copy: args.copy }, { studioId: args.studioId, timeoutMs: 60_000 }));
297
+ }
298
+ if (args.op === "mesh") {
299
+ const paths = args.paths ?? (args.path !== undefined ? [args.path] : []);
300
+ if (paths.length === 0) {
301
+ return text('mesh needs `paths` — the MeshParts to read, e.g. ["Workspace.Tree"].');
302
+ }
303
+ const read = await bridge.call("geometry.mesh", { paths },
304
+ // Each part is a separate download-and-open, so a batch of twenty is
305
+ // twenty round trips to Roblox's asset servers.
306
+ { studioId: args.studioId, timeoutMs: 120_000 });
307
+ if (read.items.length === 0) {
308
+ return text(read.failures.length > 0
309
+ ? `Nothing readable:\n ${read.failures.join("\n ")}`
310
+ : "No MeshParts in that list.");
311
+ }
312
+ const rendered = textOf(table(["name", "triangles", "vertices", "meshSize", "partSize", "renderFidelity", "collisionFidelity"], read.items, { more: `${read.totalTriangles} triangles across ${read.items.length} part(s)` }));
313
+ return text(read.failures.length > 0
314
+ ? `${rendered}\n\nSkipped:\n ${read.failures.join("\n ")}`
315
+ : rendered);
316
+ }
183
317
  // `segment` is GenerationService, not GeometryService - the same job from
184
318
  // the caller's side, a different service underneath, and far slower.
185
319
  if (args.op === "segment") {
@@ -261,13 +395,32 @@ export function registerWorldTools(context) {
261
395
  title: "Creator Store",
262
396
  description: "Searches Roblox's Creator Store and inserts models into the place.\n\n" +
263
397
  "`search` looks through the same public index Studio's own asset browser " +
264
- "uses and returns ids with names, creators, vote ratios and — the part " +
265
- "that matters — whether the model contains scripts. `insert` puts one " +
266
- "into the place by id.\n\n" +
398
+ "uses. It reports script COUNT, triangles, whether the creator is " +
399
+ "verified, whether the asset is free, and what Roblox thinks it is " +
400
+ "(\"Door/Furniture\"). `insert` puts one into the place by id.\n\n" +
401
+ "Results are ranked by approval WEIGHTED BY vote count, because the raw " +
402
+ "percentage lies: 100% from two voters outranks 82% from five thousand " +
403
+ "unless the count is taken into account. The vote count is shown beside " +
404
+ "the percentage for the same reason.\n\n" +
405
+ "Filters — `excludeScripts`, `maxTriangles`, `verifiedOnly`, `freeOnly`, " +
406
+ "`minVotes` — are applied here, not by Roblox, and several pages are " +
407
+ "fetched to fill the results. Roblox's own sort and creator filters are " +
408
+ "accepted by the endpoint and silently ignored, so they are not offered.\n\n" +
267
409
  "ALWAYS check `hasScripts` before inserting. Free models carrying " +
268
410
  "scripts are the oldest hazard on the platform, and a model dropped into " +
269
411
  "someone's game can run whatever it likes. The insert reports the script " +
270
412
  "count again, and names them, so it can still be undone.\n\n" +
413
+ "`peek` is the safer half of that: it loads the asset in memory WITHOUT " +
414
+ "putting it in the place and tells you exactly what is inside — every " +
415
+ "class, every script by name. Nothing is parented, so there is nothing " +
416
+ "to undo. Use it whenever `hasScripts` says YES and the model still " +
417
+ "looks worth having.\n\n" +
418
+ "Audio searches take a different path from everything else here. They go " +
419
+ "to the engine's own audio index, so results carry duration, artist and " +
420
+ "whether the clip is music or a sound effect — the fields that actually " +
421
+ "decide which sound you want. They return SOUND EFFECTS by default; pass " +
422
+ '`audioType: "Music"` for tracks. Filter with `minDuration` / ' +
423
+ "`maxDuration` — a footstep is under a second and a music bed is minutes.\n\n" +
271
424
  "Only public assets can be inserted. A private or deleted id fails with " +
272
425
  "a message saying so rather than inserting nothing quietly.\n\n" +
273
426
  "`bake` is unrelated to the Creator Store and does not upload anything. " +
@@ -281,19 +434,162 @@ export function registerWorldTools(context) {
281
434
  "against a RUNNING playtest server session: pass that `studioId`, and " +
282
435
  "baking a mesh the game just built is what lets clients see it.\n\n" +
283
436
  "It does not help `generate` at all. Generated meshes hold opaque " +
284
- "content, which the engine refuses to bake.",
437
+ "content, which the engine refuses to bake.\n\n" +
438
+ "THE OTHER DIRECTION: `upload` sends a local file TO Roblox and " +
439
+ "gives you the asset id. Audio, an image, a 3D model or a video, " +
440
+ "picked by extension — .mp3/.ogg/.wav/.flac, .png/.jpg/.bmp/.tga, " +
441
+ ".fbx/.gltf/.glb, .mp4/.mov. This closes the one hole nothing else " +
442
+ "here covers: a sound effect sitting in a folder on disk used to " +
443
+ "need Studio's import dialog before anything could reference it.\n\n" +
444
+ "Uploads are moderated and count against a real monthly quota. Do not " +
445
+ "guess what it is — Roblox's own guide and the live API disagree, and " +
446
+ "the account's verification level changes it. Ask `op=\"quota\"`. Do " +
447
+ "not upload speculatively, and do not re-upload to retry: the first " +
448
+ "one probably worked.\n\n" +
449
+ "`grant` gives a game or a person permission to use assets you own. " +
450
+ "You do NOT need this for your own assets in your own game — those " +
451
+ "always work. It is for a collaborator's place, or a group game you " +
452
+ "do not own. A grant to a game is PERMANENT; Roblox provides no way " +
453
+ "to revoke one, so it needs `confirm: true`.\n\n" +
454
+ "`publish` sends a .rbxl or .rbxlx from disk to a place. It SAVES a " +
455
+ "new version by default and only goes live with `confirm: true`. " +
456
+ "Note a real limitation: Roblox's publishing API does not update " +
457
+ "EditableImage, EditableMesh, PartOperation, SurfaceAppearance or " +
458
+ "BaseWrap instances, and reports success anyway — publish from " +
459
+ "Studio if the place uses any of those.\n\n" +
460
+ "Publishing alone does NOT move anyone already playing — they stay " +
461
+ "on their server running the old code until it empties. Pass " +
462
+ "`restart: true` to roll live servers onto the new version, which " +
463
+ "bleeds them off over 10 minutes rather than dropping players.\n\n" +
464
+ "`quota` reports how many uploads are left before Roblox starts " +
465
+ "refusing them, per asset type, read from the account itself. Check it " +
466
+ "before a batch rather than discovering the ceiling halfway through.\n\n" +
467
+ "All of these need an Open Cloud API key. The user sets it once by " +
468
+ "typing `cloud` in the Studio panel; never ask them to paste a key " +
469
+ "into this conversation.",
285
470
  inputSchema: {
286
471
  op: z
287
- .enum(["search", "insert", "bake"])
288
- .describe("'search' finds assets, 'insert' adds one to the place, 'bake' makes " +
289
- "in-memory mesh and image data replicate."),
472
+ .enum(["search", "peek", "insert", "bake", "upload", "grant", "publish", "quota"])
473
+ .describe("'search' finds assets, 'peek' shows what is inside one without " +
474
+ "inserting it, 'insert' adds one to the place, 'bake' makes " +
475
+ "in-memory mesh and image data replicate, 'upload' sends a " +
476
+ "local file to Roblox, 'grant' shares one you own with " +
477
+ "another game or person, 'publish' pushes a place file live."),
478
+ file: z
479
+ .string()
480
+ .optional()
481
+ .describe("upload/publish: path to the file on disk. Omit on `upload` to " +
482
+ "check whether the credentials are set up without sending " +
483
+ "anything."),
484
+ description: z
485
+ .string()
486
+ .optional()
487
+ .describe("upload only: public description. Moderated."),
488
+ assetType: z
489
+ .enum(["Audio", "Decal", "Model", "Video"])
490
+ .optional()
491
+ .describe("upload only: override the type derived from the extension. " +
492
+ "Rarely right — Roblox validates the type against the file's " +
493
+ "real content."),
494
+ insertAs: z
495
+ .string()
496
+ .optional()
497
+ .describe("upload only: put the finished asset in the place at this " +
498
+ "parent path once it is approved. Decals and Models only — an " +
499
+ "audio id belongs in an AudioPlayer, so use `audio " +
500
+ "op=\"graph\"` with the id this returns."),
501
+ assetIds: z
502
+ .array(z.number().int())
503
+ .max(50)
504
+ .optional()
505
+ .describe("grant only: the assets to share. You must own them."),
506
+ subjectType: z
507
+ .enum(["Universe", "User", "Group"])
508
+ .optional()
509
+ .describe("grant only: who gets access. 'Universe' is a game and is the " +
510
+ "usual one. Defaults to 'Universe'."),
511
+ subjectId: z
512
+ .string()
513
+ .optional()
514
+ .describe("grant only: the universe, user or group id. Omit for a " +
515
+ "Universe grant to use the one set with `cloud universe <id>`."),
516
+ universeId: z.string().optional().describe("publish only: which game. Omit to use `cloud universe`."),
517
+ placeId: z.string().optional().describe("publish only: which place. Omit to use `cloud place`."),
518
+ restart: z
519
+ .boolean()
520
+ .optional()
521
+ .describe("publish only: also roll live servers onto the new version. " +
522
+ "Without this, players already in a server keep running the " +
523
+ "old code until it empties."),
524
+ stripScripts: z
525
+ .boolean()
526
+ .optional()
527
+ .describe("insert only: delete every Script, LocalScript and ModuleScript " +
528
+ "from the asset on the way in. The safe way to take geometry " +
529
+ "from a free model without taking whatever its scripts do."),
530
+ confirm: z
531
+ .boolean()
532
+ .optional()
533
+ .describe("Required to make a `publish` go live rather than only save, " +
534
+ "and required for `grant`, whose effect Roblox cannot undo."),
290
535
  keyword: z.string().optional().describe("search only: what to look for, e.g. \"medieval door\"."),
291
536
  category: z
292
537
  .enum(["model", "decal", "mesh", "audio"])
293
538
  .default("model")
294
539
  .describe("search only: what kind of asset. Only models insert as instances."),
295
540
  limit: z.number().int().min(1).max(20).default(8).describe("search only: how many results."),
296
- assetId: z.number().int().positive().optional().describe("insert only: the asset id to insert."),
541
+ excludeScripts: z
542
+ .boolean()
543
+ .default(false)
544
+ .describe("search only: drop every result that contains scripts. The single " +
545
+ "safest filter — a free model's scripts run with your game's full " +
546
+ "permissions."),
547
+ maxTriangles: z
548
+ .number()
549
+ .int()
550
+ .min(1)
551
+ .optional()
552
+ .describe("search only: drop models heavier than this. A prop you place fifty " +
553
+ "times wants to be in the hundreds, not the tens of thousands."),
554
+ verifiedOnly: z
555
+ .boolean()
556
+ .default(false)
557
+ .describe("search only: only results from verified creators."),
558
+ freeOnly: z
559
+ .boolean()
560
+ .default(false)
561
+ .describe("search only: drop paid assets, which cannot just be inserted."),
562
+ minVotes: z
563
+ .number()
564
+ .int()
565
+ .min(0)
566
+ .optional()
567
+ .describe("search only: require at least this many votes. Filters out models " +
568
+ "with a perfect score from three people."),
569
+ minDuration: z
570
+ .number()
571
+ .min(0)
572
+ .optional()
573
+ .describe("audio search only: shortest clip to return, in seconds."),
574
+ maxDuration: z
575
+ .number()
576
+ .min(0)
577
+ .optional()
578
+ .describe("audio search only: longest clip to return, in seconds. Set it to 3 " +
579
+ "or so for effects — otherwise full-length music dominates the results."),
580
+ audioType: z
581
+ .enum(["SoundEffect", "Music"])
582
+ .default("SoundEffect")
583
+ .describe("audio search only. Defaults to SoundEffect, which is what a noise in " +
584
+ 'a game is. Ask for "Music" only when you want a track — the engine\'s ' +
585
+ 'own default is Music, and it makes "footstep" return three-minute ' +
586
+ "ambient songs with footsteps in the title."),
587
+ assetId: z
588
+ .number()
589
+ .int()
590
+ .positive()
591
+ .optional()
592
+ .describe("insert and peek only: the asset id."),
297
593
  parent: z.string().optional().describe("insert only: where to put it. Defaults to Workspace."),
298
594
  position: z
299
595
  .string()
@@ -309,28 +605,215 @@ export function registerWorldTools(context) {
309
605
  },
310
606
  destructive: false,
311
607
  }, async (args) => {
608
+ if (args.op === "peek") {
609
+ if (!args.assetId)
610
+ return text("peek needs an `assetId`.");
611
+ const inside = await bridge.call("assets.peek", { assetId: args.assetId }, { studioId: args.studioId, timeoutMs: 60_000 });
612
+ const lines = [
613
+ `Asset ${inside.assetId}: ${inside.descendants} instances, nothing inserted.`,
614
+ "",
615
+ `Top level: ${inside.roots.join(", ")}`,
616
+ "",
617
+ textOf(table(["className", "count"], inside.classes)),
618
+ ];
619
+ lines.push(inside.scriptCount === 0
620
+ ? "\nNo scripts — safe to insert."
621
+ : `\n${inside.scriptCount} script(s), and inserting runs them:\n ` +
622
+ inside.scripts.join("\n ") +
623
+ "\nRead them with `script_read` after inserting, or leave this asset alone.");
624
+ return text(lines.join("\n"));
625
+ }
626
+ if (args.op === "search" && args.category === "audio") {
627
+ if (!args.keyword)
628
+ return text("search needs a `keyword`.");
629
+ const found = await bridge.call("assets.audio", {
630
+ keyword: args.keyword,
631
+ limit: args.limit,
632
+ minDuration: args.minDuration,
633
+ maxDuration: args.maxDuration,
634
+ audioType: args.audioType,
635
+ }, { studioId: args.studioId, timeoutMs: 45_000 });
636
+ if (found.items.length === 0) {
637
+ return text(`No audio matched "${args.keyword}".` +
638
+ (args.minDuration !== undefined || args.maxDuration !== undefined
639
+ ? " The duration filter may be too narrow — try it without one."
640
+ : ""));
641
+ }
642
+ return text(textOf(table(["assetId", "title", "artist", "duration", "audioType", "endorsed"], found.items, {
643
+ more: `${found.audioType}s only; duration in seconds; use the assetId as a ` +
644
+ "Sound's SoundId" +
645
+ (found.audioType === "SoundEffect"
646
+ ? '. Pass audioType="Music" for tracks.'
647
+ : "."),
648
+ })));
649
+ }
312
650
  if (args.op === "search") {
313
651
  if (!args.keyword)
314
652
  return text("search needs a `keyword`.");
315
- const found = await searchCreatorStore(args.keyword, args.category, args.limit);
316
- if (found.length === 0) {
317
- return text(`Nothing matched "${args.keyword}" in ${args.category}s.`);
653
+ const found = await searchCreatorStore(args.keyword, args.category, args.limit, {
654
+ excludeScripts: args.excludeScripts,
655
+ maxTriangles: args.maxTriangles,
656
+ verifiedOnly: args.verifiedOnly,
657
+ freeOnly: args.freeOnly,
658
+ minVotes: args.minVotes,
659
+ });
660
+ if (found.items.length === 0) {
661
+ const filtered = args.excludeScripts || args.verifiedOnly || args.freeOnly || args.maxTriangles || args.minVotes;
662
+ return text(`Nothing matched "${args.keyword}" in ${args.category}s` +
663
+ (filtered
664
+ ? `. ${found.scanned} result(s) were checked and every one was filtered out — loosen a filter.`
665
+ : "."));
318
666
  }
319
- const rows = found.map((entry) => ({
667
+ const rows = found.items.map((entry) => ({
320
668
  assetId: entry.asset?.id ?? 0,
321
669
  name: entry.asset?.name ?? "?",
322
- creator: entry.creator?.name ?? "?",
323
- approval: entry.voting?.upVotePercent ? `${entry.voting.upVotePercent}%` : "—",
324
- hasScripts: entry.asset?.hasScripts ? "YES" : "no",
670
+ creator: (entry.creator?.name ?? "?") + (entry.creator?.isVerifiedCreator ? " ✓" : ""),
671
+ /*
672
+ * Votes shown beside the percentage, never alone. "100%" is what two
673
+ * friends upvoting looks like, and it sorts above a model used by
674
+ * thousands unless the count is on screen next to it.
675
+ */
676
+ approval: entry.voting?.upVotePercent !== undefined
677
+ ? `${entry.voting.upVotePercent}% (${entry.voting.voteCount ?? 0})`
678
+ : "—",
679
+ scripts: entry.asset?.hasScripts ? (entry.asset.scriptCount ?? "yes") : "no",
325
680
  triangles: entry.asset?.modelTechnicalDetails?.objectMeshSummary?.triangles ?? "—",
681
+ free: entry.fiatProduct?.isFree === false ? "PAID" : "free",
682
+ kind: (entry.asset?.objectTypes ?? []).join("/") || "—",
683
+ }));
684
+ const risky = rows.filter((row) => row.scripts !== "no");
685
+ const paid = rows.filter((row) => row.free === "PAID");
686
+ const notes = [];
687
+ notes.push(`${found.items.length} shown of ${found.scanned} checked` +
688
+ (found.total !== undefined ? ` (${found.total} exist)` : "") +
689
+ "; ranked by approval weighted by vote count.");
690
+ if (risky.length > 0) {
691
+ notes.push(`${risky.length} contain scripts (${risky.map((r) => r.name).join(", ")}). ` +
692
+ "Inserting one runs whatever its author put in it — use `peek` to read them " +
693
+ "first, or pass excludeScripts.");
694
+ }
695
+ else {
696
+ notes.push("None of these contain scripts.");
697
+ }
698
+ if (paid.length > 0) {
699
+ notes.push(`${paid.length} are PAID and cannot simply be inserted: ${paid
700
+ .map((r) => r.name)
701
+ .join(", ")}.`);
702
+ }
703
+ return text(textOf(table(["assetId", "name", "creator", "approval", "scripts", "triangles", "free", "kind"], rows)) +
704
+ "\n\n" +
705
+ notes.join("\n"));
706
+ }
707
+ if (args.op === "quota") {
708
+ return json(await assetQuotas(await requireCredentials()));
709
+ }
710
+ if (args.op === "upload") {
711
+ if (!args.file) {
712
+ const { credentialStatus } = await import("../lib/credentials.js");
713
+ const status = await credentialStatus();
714
+ if (!status.hasKey || status.creatorId === null) {
715
+ throw new ToolError("NO_CREDENTIALS", "No Open Cloud key is set up, so nothing can be uploaded.", "Ask the user to type `cloud` in the Studio panel \u2014 it walks " +
716
+ "through creating the key and stores it safely. Never ask them " +
717
+ "to paste a key into this conversation.");
718
+ }
719
+ return text(`Ready to upload: key set, uploading as ${status.creatorField} ` +
720
+ `${status.creatorId}. Call again with \`file\`.`);
721
+ }
722
+ const credentials = await requireCredentials();
723
+ const uploaded = await uploadAsset(credentials, {
724
+ file: args.file,
725
+ name: args.name,
726
+ description: args.description,
727
+ assetType: args.assetType,
728
+ });
729
+ const assetId = String(uploaded["assetId"]);
730
+ const approved = uploaded["approved"] === true;
731
+ /*
732
+ * Only inserted once Roblox says it is approved. Putting a rejected
733
+ * asset into the place leaves an instance pointing at nothing, which
734
+ * reads as a bug here rather than as a moderation decision.
735
+ */
736
+ if (args.insertAs && approved) {
737
+ if (uploaded["assetType"] === "Audio") {
738
+ uploaded["inserted"] = false;
739
+ uploaded["note"] =
740
+ "Audio is not inserted on its own \u2014 the id goes into an " +
741
+ `AudioPlayer. Build one with \`audio op="graph" ` +
742
+ `asset="rbxassetid://${assetId}"\`.`;
743
+ }
744
+ else if (uploaded["assetType"] === "Decal") {
745
+ uploaded["inserted"] = await bridge.call("instances.create", {
746
+ instances: [
747
+ {
748
+ className: "Decal",
749
+ name: args.name ?? "Decal",
750
+ parent: args.insertAs,
751
+ properties: {
752
+ Texture: { value: `rbxassetid://${assetId}`, type: "ContentId" },
753
+ },
754
+ },
755
+ ],
756
+ }, { studioId: args.studioId, timeoutMs: 30_000 });
757
+ }
758
+ else {
759
+ uploaded["inserted"] = await bridge.call("assets.insert", { assetId: Number(assetId), parent: args.insertAs }, { studioId: args.studioId, timeoutMs: 90_000 });
760
+ }
761
+ }
762
+ return json(uploaded, approved
763
+ ? `Use it as rbxassetid://${assetId}.`
764
+ : `Moderation says ${uploaded["moderation"]}. The id exists but may ` +
765
+ "not load until review finishes.");
766
+ }
767
+ if (args.op === "grant") {
768
+ if (!args.assetIds || args.assetIds.length === 0) {
769
+ throw new ToolError("BAD_PARAMS", "grant needs `assetIds`.");
770
+ }
771
+ const subjectType = args.subjectType ?? "Universe";
772
+ const subjectId = subjectType === "Universe"
773
+ ? await requireUniverse(args.subjectId)
774
+ : args.subjectId;
775
+ if (!subjectId) {
776
+ throw new ToolError("BAD_PARAMS", `grant to a ${subjectType} needs a \`subjectId\`.`);
777
+ }
778
+ if (args.confirm !== true) {
779
+ throw new ToolError("NEEDS_CONFIRM", "Granting a game access to an asset is permanent.", "Roblox provides no way to revoke it. Pass confirm: true once you " +
780
+ "are sure of the asset ids and the subject.");
781
+ }
782
+ const credentials = await requireCredentials();
783
+ return json(await grantAssets(credentials, {
784
+ assetIds: args.assetIds,
785
+ subjectType,
786
+ subjectId,
326
787
  }));
327
- const risky = rows.filter((row) => row.hasScripts === "YES");
328
- return text(textOf(table(["assetId", "name", "creator", "approval", "hasScripts", "triangles"], rows)) +
329
- (risky.length > 0
330
- ? `\n\n${risky.length} of these contain scripts (${risky
331
- .map((r) => r.name)
332
- .join(", ")}). Inserting one runs whatever its author put in it — prefer a script-free model unless the scripts are the point.`
333
- : "\n\nNone of these contain scripts."));
788
+ }
789
+ if (args.op === "publish") {
790
+ if (!args.file)
791
+ throw new ToolError("BAD_PARAMS", "publish needs a `file` (.rbxl or .rbxlx).");
792
+ const credentials = await requireCredentials();
793
+ const universeId = await requireUniverse(args.universeId);
794
+ const placeId = await requirePlace(args.placeId);
795
+ const published = await publishPlace(credentials, {
796
+ file: args.file,
797
+ universeId,
798
+ placeId,
799
+ publish: args.confirm === true,
800
+ });
801
+ /*
802
+ * Restarting a version that was only SAVED would roll servers onto
803
+ * the version before it, which is the opposite of what was asked
804
+ * for. So the two flags are checked together rather than
805
+ * separately.
806
+ */
807
+ if (args.restart === true) {
808
+ published['restart'] =
809
+ args.confirm === true
810
+ ? await restartServers(credentials, {
811
+ universeId,
812
+ placeIds: [Number(placeId)],
813
+ })
814
+ : "Not restarted: the file was only saved, not published. A restart now would roll servers onto the PREVIOUS version.";
815
+ }
816
+ return json(published);
334
817
  }
335
818
  if (args.op === "bake") {
336
819
  if (!args.paths || args.paths.length === 0) {
@@ -366,6 +849,7 @@ export function registerWorldTools(context) {
366
849
  parent: args.parent,
367
850
  position: args.position,
368
851
  name: args.name,
852
+ stripScripts: args.stripScripts,
369
853
  },
370
854
  // Downloading an asset goes out to Roblox and back.
371
855
  { studioId: args.studioId, timeoutMs: 90_000 });
@@ -428,14 +912,108 @@ export function registerWorldTools(context) {
428
912
  "Groups are not undoable and not scoped to a session: `remove` when one " +
429
913
  "was created to try something and is no longer wanted, rather than " +
430
914
  "leaving it registered in the place indefinitely. The built-in " +
431
- "\"Default\" group cannot be removed.",
915
+ "\"Default\" group cannot be removed.\n\n" +
916
+ "Groups belong to a world, not to the place. The Workspace is the " +
917
+ "default and is what nearly every question is about; a `WorldModel` " +
918
+ "inside a ViewportFrame keeps its own separate registry, so pass " +
919
+ "`worldModel` to reach that one. A group of the same name in each is " +
920
+ "two different groups.\n\n" +
921
+ "THE SAME TOOL ANSWERS WHAT IS ACTUALLY THERE. `cast` fires a ray, " +
922
+ "block or sphere and reports the first thing it meets — the part, the " +
923
+ "hit point, the surface normal, the material and the distance. " +
924
+ "`overlap` lists everything inside a box, a radius, or overlapping " +
925
+ "an existing part.\n\n" +
926
+ "That is the one question the Explorer cannot answer. A path tells " +
927
+ "you an instance exists and where its pivot sits; it does not tell " +
928
+ "you the door frame is clipping into the wall, that the spawn is " +
929
+ "buried a stud inside the floor, or that nothing stands between the " +
930
+ "turret and the player. Geometry wrong in exactly those ways looks " +
931
+ "perfect in `inspect`.\n\n" +
932
+ "The queries live here because they ARE collision queries: they " +
933
+ "honour the very groups the other half of this tool manages. A cast " +
934
+ "run in the wrong `collisionGroup` reports a clear path through a " +
935
+ "wall the player cannot walk through — a wrong answer " +
936
+ "indistinguishable from a right one. A miss comes back as " +
937
+ "`hit: false`, which is a real answer and usually the one being " +
938
+ "checked for.",
432
939
  inputSchema: {
433
940
  action: z
434
- .enum(["list", "create", "assign", "collidable", "remove"])
941
+ .enum(["list", "create", "assign", "collidable", "remove", "cast", "overlap"])
435
942
  .default("list")
436
- .describe("'list' shows existing groups and changes nothing. 'remove' " +
437
- "unregisters a group entirely — not the same as un-assigning " +
438
- "parts from it."),
943
+ .describe("Groups: 'list' shows them and changes nothing, then 'create', " +
944
+ "'assign', 'collidable', 'remove' (which unregisters a group " +
945
+ "entirely — not the same as un-assigning parts). Queries: " +
946
+ "'cast' fires a shape and reports the first hit, 'overlap' " +
947
+ "lists what is inside a volume."),
948
+ shape: z
949
+ .enum(["ray", "block", "sphere"])
950
+ .optional()
951
+ .describe("cast only: 'ray' is a line and the usual choice. 'block' and " +
952
+ "'sphere' sweep a volume along the same path — use them when " +
953
+ "the thing moving has width, e.g. whether a character fits " +
954
+ "through a gap rather than whether a point does."),
955
+ from: z.string().optional().describe('cast only: where the cast starts, e.g. "12, 0, 5".'),
956
+ to: z
957
+ .string()
958
+ .optional()
959
+ .describe("cast only: a point to aim at. Use this for sightlines — it saves " +
960
+ "working out a direction vector, which is where sign errors live."),
961
+ direction: z
962
+ .string()
963
+ .optional()
964
+ .describe('cast only: which way to go, e.g. "0, -1, 0" for down. Used with `distance`.'),
965
+ distance: z
966
+ .number()
967
+ .optional()
968
+ .describe("cast only: how far along `direction`. Defaults to 100."),
969
+ size: z
970
+ .string()
971
+ .optional()
972
+ .describe('cast shape="block" or overlap region="box": the volume size.'),
973
+ radius: z
974
+ .number()
975
+ .optional()
976
+ .describe('cast shape="sphere" or overlap region="radius": the radius.'),
977
+ region: z
978
+ .enum(["box", "radius", "part"])
979
+ .optional()
980
+ .describe("overlap only: 'box' and 'radius' need `at`; 'part' takes " +
981
+ "`path` and reports what overlaps that part — the fastest way " +
982
+ "to find things clipping through each other. Defaults to 'box'."),
983
+ at: z.string().optional().describe('overlap only: the centre, for region "box" or "radius".'),
984
+ path: z.string().optional().describe('overlap region="part" only: the part to test against.'),
985
+ only: z
986
+ .array(z.string())
987
+ .optional()
988
+ .describe("cast/overlap: consider ONLY these instances and their descendants."),
989
+ ignore: z
990
+ .array(z.string())
991
+ .optional()
992
+ .describe("cast/overlap: skip these and their descendants. The usual case " +
993
+ "is the character doing the looking, which otherwise blocks its " +
994
+ "own cast at zero distance."),
995
+ collisionGroup: z
996
+ .string()
997
+ .optional()
998
+ .describe("cast/overlap: run the query as if from a part in this group. " +
999
+ "Required for a truthful answer in any place that uses groups."),
1000
+ respectCanCollide: z
1001
+ .boolean()
1002
+ .optional()
1003
+ .describe("cast/overlap: skip parts with CanCollide off. Off by default, " +
1004
+ "matching the engine — leave it off to ask what is there, turn " +
1005
+ "it on to ask what would stop a player."),
1006
+ ignoreWater: z
1007
+ .boolean()
1008
+ .optional()
1009
+ .describe("cast only: pass through terrain water instead of hitting it."),
1010
+ limit: z
1011
+ .number()
1012
+ .int()
1013
+ .min(1)
1014
+ .max(500)
1015
+ .optional()
1016
+ .describe("overlap only: how many parts to list. Defaults to 50."),
439
1017
  group: z.string().optional().describe("The group's name. Required for everything but list."),
440
1018
  paths: z
441
1019
  .array(z.string())
@@ -447,22 +1025,68 @@ export function registerWorldTools(context) {
447
1025
  .boolean()
448
1026
  .default(true)
449
1027
  .describe("collidable only: whether the two groups collide. False makes them pass through."),
1028
+ worldModel: z
1029
+ .string()
1030
+ .optional()
1031
+ .describe("Path to a WorldModel whose own collision groups this call is " +
1032
+ "about, e.g. \"StarterGui.Preview.Viewport.WorldModel\". Omit " +
1033
+ "for the Workspace, which is what you want unless the parts in " +
1034
+ "question live inside a ViewportFrame."),
450
1035
  studioId: z.string().optional().describe("Target Studio; omit for the active one."),
451
1036
  },
452
1037
  destructive: false,
453
1038
  }, async (args) => {
1039
+ if (args.action === "cast" || args.action === "overlap") {
1040
+ const query = await bridge.call(args.action === "cast" ? "spatial.cast" : "spatial.overlap", {
1041
+ shape: args.shape,
1042
+ from: args.from,
1043
+ to: args.to,
1044
+ direction: args.direction,
1045
+ distance: args.distance,
1046
+ size: args.size,
1047
+ radius: args.radius,
1048
+ region: args.region,
1049
+ at: args.at,
1050
+ path: args.path,
1051
+ only: args.only,
1052
+ ignore: args.ignore,
1053
+ collisionGroup: args.collisionGroup,
1054
+ respectCanCollide: args.respectCanCollide,
1055
+ ignoreWater: args.ignoreWater,
1056
+ limit: args.limit,
1057
+ worldModel: args.worldModel,
1058
+ }, { studioId: args.studioId, timeoutMs: 30_000 });
1059
+ if (args.action === "cast" && query["hit"] === false) {
1060
+ return json(query, "Nothing was hit. If that is a surprise: a `direction` pointing " +
1061
+ "the wrong way, a `distance` shorter than the gap, or an " +
1062
+ "`only` filter excluding what you meant to find.");
1063
+ }
1064
+ return json(query);
1065
+ }
454
1066
  const response = await bridge.call("world.collision", {
455
1067
  action: args.action,
456
1068
  group: args.group,
457
1069
  paths: args.paths,
458
1070
  with: args.with,
459
1071
  collidable: args.collidable,
1072
+ worldModel: args.worldModel,
460
1073
  }, { studioId: args.studioId });
461
1074
  if (args.action === "list") {
462
1075
  const groups = response.groups ?? [];
1076
+ const where = response.world ?? "Workspace";
463
1077
  if (groups.length === 0)
464
- return text("No collision groups are registered.");
465
- return text(textOf(table(["name", "mask"], groups)));
1078
+ return text(`No collision groups are registered in ${where}.`);
1079
+ return text(textOf(table(["name", "passes through", "mask"], groups.map((group) => ({
1080
+ name: group.name,
1081
+ "passes through": group.passesThrough ?? "?",
1082
+ mask: group.mask,
1083
+ })), {
1084
+ // The ceiling is low enough to hit, and a caller registering
1085
+ // groups in a loop has no other way to find out where it is.
1086
+ more: response.max === undefined
1087
+ ? where
1088
+ : `${where}: ${groups.length} of ${response.max} groups used`,
1089
+ })));
466
1090
  }
467
1091
  return json(response);
468
1092
  });