@el4cteo/rbx-studio-mcp 0.8.4 → 0.8.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 (65) hide show
  1. package/README.md +20 -3
  2. package/dist/bridge/rpc.js +1 -1
  3. package/dist/bridge/rpc.js.map +1 -1
  4. package/dist/index.js +7 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +75 -0
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/errors.js +5 -4
  9. package/dist/lib/errors.js.map +1 -1
  10. package/dist/lib/format.js +92 -25
  11. package/dist/lib/format.js.map +1 -1
  12. package/dist/lib/notices.js +29 -0
  13. package/dist/lib/notices.js.map +1 -0
  14. package/dist/lib/protocol.js.map +1 -1
  15. package/dist/lib/sync.js +1141 -0
  16. package/dist/lib/sync.js.map +1 -0
  17. package/dist/lib/syncplan.js +338 -0
  18. package/dist/lib/syncplan.js.map +1 -0
  19. package/dist/lib/tool.js +9 -2
  20. package/dist/lib/tool.js.map +1 -1
  21. package/dist/tools/discover.js +6 -1
  22. package/dist/tools/discover.js.map +1 -1
  23. package/dist/tools/exec.js +5 -0
  24. package/dist/tools/exec.js.map +1 -1
  25. package/dist/tools/instances.js +2 -2
  26. package/dist/tools/instances.js.map +1 -1
  27. package/dist/tools/perf.js +28 -3
  28. package/dist/tools/perf.js.map +1 -1
  29. package/dist/tools/screenshot.js +7 -3
  30. package/dist/tools/screenshot.js.map +1 -1
  31. package/dist/tools/scripts.js +110 -28
  32. package/dist/tools/scripts.js.map +1 -1
  33. package/dist/tools/sync.js +169 -0
  34. package/dist/tools/sync.js.map +1 -0
  35. package/package.json +4 -4
  36. package/plugin/src/Config.luau +1 -1
  37. package/plugin/src/Dispatch.luau +131 -90
  38. package/plugin/src/ExecRuntime.luau +190 -168
  39. package/plugin/src/LogBuffer.luau +38 -9
  40. package/plugin/src/Paths.luau +42 -0
  41. package/plugin/src/Phrase.luau +41 -0
  42. package/plugin/src/ScriptEdit.luau +94 -8
  43. package/plugin/src/Serialize.luau +16 -1
  44. package/plugin/src/Transport.luau +5 -2
  45. package/plugin/src/Undo.luau +74 -10
  46. package/plugin/src/handlers/Capture.luau +818 -809
  47. package/plugin/src/handlers/Debug.luau +19 -16
  48. package/plugin/src/handlers/Discover.luau +31 -1
  49. package/plugin/src/handlers/Perf.luau +100 -21
  50. package/plugin/src/handlers/Scripts.luau +274 -90
  51. package/plugin/src/handlers/Sync.luau +968 -0
  52. package/plugin/src/init.server.luau +33 -2
  53. package/scripts/build.mjs +14 -0
  54. package/scripts/sync-fake.mjs +191 -0
  55. package/scripts/test-live-sync-scale.mjs +150 -0
  56. package/scripts/test-live-sync.mjs +232 -0
  57. package/scripts/test-live-tools.mjs +6 -1
  58. package/scripts/test-plugin.mjs +16 -0
  59. package/scripts/test-results.mjs +41 -0
  60. package/scripts/test-sync-more.mjs +228 -0
  61. package/scripts/test-sync.mjs +245 -0
  62. package/dist/tools/spatial.js +0 -135
  63. package/dist/tools/spatial.js.map +0 -1
  64. package/dist/tools/upload.js +0 -294
  65. package/dist/tools/upload.js.map +0 -1
@@ -44,6 +44,7 @@ local Device = require(script.handlers.Device)
44
44
  local Data = require(script.handlers.Data)
45
45
  local Api = require(script.handlers.Api)
46
46
  local Scripts = require(script.handlers.Scripts)
47
+ local Sync = require(script.handlers.Sync)
47
48
  local Session = require(script.handlers.Session)
48
49
 
49
50
  local SETTING_PORT = "port"
@@ -196,6 +197,7 @@ Audio.register()
196
197
  Exec.register()
197
198
  Viewport.register()
198
199
  Scripts.register()
200
+ Sync.register()
199
201
  Input.register()
200
202
  Device.register()
201
203
  Data.register()
@@ -580,6 +582,12 @@ Commands.setup({
580
582
 
581
583
  Console.setPromptChat(chat)
582
584
 
585
+ -- Sync reports what it changed in the log; handed a function rather than the
586
+ -- console itself, for the same reason `Commands` is.
587
+ Sync.setup(function(level, message, detail)
588
+ Console.log(level :: any, message, detail)
589
+ end)
590
+
583
591
  -- The saved preset, now that there is something to paint. Done immediately
584
592
  -- after mounting and before the first line is logged, so nothing is ever drawn
585
593
  -- in the wrong palette.
@@ -755,7 +763,30 @@ end
755
763
  and any `*Async` call does -- and serialising them would let one slow edit
756
764
  stall every other request on the stream.
757
765
  ]]
758
- local function onCommand(id: string, op: string, params: { [string]: any }?)
766
+ --[[
767
+ Commands that run without a line in the log.
768
+
769
+ `sync watch` asks what changed twice a second for as long as it runs, and
770
+ `sync.log` exists to print a line of its own. Logging either call would bury
771
+ everything else the panel shows under a heartbeat nobody needs to see.
772
+ ]]
773
+ local QUIET: { [string]: boolean } = {
774
+ ["sync.changes"] = true,
775
+ ["sync.log"] = true,
776
+ ["sync.shape"] = true,
777
+ ["sync.revisions"] = true,
778
+ }
779
+
780
+ local function onCommand(id: string, op: string, params: { [string]: any }?, deadlineMs: number?)
781
+ -- A sync op can also ask to be quiet: `watch` runs the same scans a manual
782
+ -- sync does, and logging each would print a heartbeat. What a watch
783
+ -- actually changes is still logged, by `sync.apply` and `sync.log`.
784
+ if QUIET[op] or (string.sub(op, 1, 5) == "sync." and params ~= nil and params.quiet == true) then
785
+ task.spawn(function()
786
+ Transport.sendResult(Dispatch.invoke(id, op, params, deadlineMs))
787
+ end)
788
+ return
789
+ end
759
790
  task.spawn(function()
760
791
  local startedAt = os.clock()
761
792
  --[[
@@ -778,7 +809,7 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
778
809
  ]]
779
810
  Console.beginCall(title, Phrase.kindOf(op))
780
811
 
781
- local result = Dispatch.invoke(id, op, params)
812
+ local result = Dispatch.invoke(id, op, params, deadlineMs)
782
813
  local milliseconds = (os.clock() - startedAt) * 1000
783
814
 
784
815
  --[[
@@ -0,0 +1,14 @@
1
+ /** Build from a clean output directory so removed tools cannot enter npm packs. */
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, realpathSync, rmSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
8
+ const output = join(root, "dist");
9
+ if (existsSync(output)) {
10
+ // Refuse junctions/symlinks outside the exact generated-output directory.
11
+ if (realpathSync(output) !== output) throw new Error("dist resolves outside the expected build directory");
12
+ rmSync(output, { recursive: true, force: true });
13
+ }
14
+ execFileSync(process.execPath, [join(root, "node_modules", "typescript", "bin", "tsc")], { cwd: root, stdio: "inherit" });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * An in-memory Studio answering the plugin's `sync.*` ops, for the offline
3
+ * sync tests. It keeps the plugin's contracts -- revisions, writes and moves
4
+ * conditional on them, per-item results, change tracking that also reports
5
+ * the echo of sync's own writes -- without any of its Roblox.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { ToolError } from "../dist/lib/errors.js";
9
+
10
+ export const fingerprint = (source) => createHash("sha1").update(source).digest("hex").slice(0, 12);
11
+
12
+ /** A scan from a flat map of script paths, the way the plugin reports one. */
13
+ export function scanOf(scripts, folders = {}) {
14
+ const nodes = [];
15
+ const index = new Map();
16
+ const nodeOf = (segments, className) => {
17
+ const key = segments.join(".");
18
+ if (index.has(key)) return index.get(key);
19
+ const parent = segments.length > 1 ? nodeOf(segments.slice(0, -1), folders[segments.slice(0, -1).join(".")] ?? "Folder") : 0;
20
+ nodes.push({ path: key, name: segments.at(-1), className, parent });
21
+ index.set(key, nodes.length);
22
+ return nodes.length;
23
+ };
24
+ const items = [];
25
+ for (const [scriptPath, info] of Object.entries(scripts)) {
26
+ const node = nodeOf(scriptPath.split("."), info.className);
27
+ nodes[node - 1].className = info.className;
28
+ items.push({ node, revision: info.revision, fileSynced: info.fileSynced });
29
+ }
30
+ return { nodes, items, roots: [...new Set(Object.keys(scripts).map((key) => key.split(".")[0]))] };
31
+ }
32
+
33
+ /** `{value, type}` property specs back to plain values, as Studio would store them. */
34
+ function untype(spec) {
35
+ const out = { className: spec.className, name: spec.name };
36
+ if (spec.properties) {
37
+ out.properties = Object.fromEntries(
38
+ Object.entries(spec.properties).map(([key, value]) => [key, value && typeof value === "object" && "value" in value ? value.value : value]),
39
+ );
40
+ }
41
+ if (spec.attributes) out.attributes = spec.attributes;
42
+ if (spec.tags) out.tags = spec.tags;
43
+ if (spec.children) out.children = spec.children.map(untype);
44
+ for (const key of Object.keys(out)) if (out[key] === undefined) delete out[key];
45
+ return out;
46
+ }
47
+
48
+ const classesIn = (spec, into = new Set()) => {
49
+ into.add(spec.className);
50
+ for (const child of spec.children ?? []) classesIn(child, into);
51
+ return into;
52
+ };
53
+
54
+ export function fakeStudio(placeId = 1) {
55
+ const scripts = new Map(); // path -> { className, source }
56
+ const trees = new Map(); // path -> build spec (plain values)
57
+ const dirty = new Set();
58
+ const logged = [];
59
+ const session = { studioId: "edit", placeId, placeName: "Test", context: "edit" };
60
+ const chain = (item) => [item.parentPath, ...(item.parents ?? []).map((link) => link.name)].filter(Boolean).join(".");
61
+ const calls = [];
62
+
63
+ const bridge = {
64
+ scripts,
65
+ trees,
66
+ logged,
67
+ calls,
68
+ /** Studio restarted: same place, new session id. */
69
+ restart() {
70
+ session.studioId = `${session.studioId}+`;
71
+ },
72
+ /** An edit made in Studio: tracked, as the plugin's events would. */
73
+ edit(path, source) {
74
+ scripts.get(path).source = source;
75
+ dirty.add(path);
76
+ },
77
+ async sessions() {
78
+ return { list: [session], activeId: null, activeIsChosen: false };
79
+ },
80
+ async call(op, params = {}, options = {}) {
81
+ calls.push(op);
82
+ if (options.studioId !== undefined && options.studioId !== session.studioId) {
83
+ throw new ToolError("UNKNOWN_STUDIO", `No connected Studio has id "${options.studioId}".`);
84
+ }
85
+ switch (op) {
86
+ case "sync.scan": {
87
+ const byPath = {};
88
+ for (const [key, value] of scripts) {
89
+ byPath[key] = { className: value.className, revision: params.revisions === false ? undefined : fingerprint(value.source) };
90
+ }
91
+ const scan = scanOf(byPath);
92
+ scan.roots = ["ServerScriptService", "ReplicatedStorage", "StarterGui"];
93
+ return scan;
94
+ }
95
+ case "sync.shape":
96
+ return { shape: [...scripts.keys()].sort().join("\n") };
97
+ case "sync.read":
98
+ return {
99
+ items: params.paths.map((key) =>
100
+ scripts.has(key)
101
+ ? { path: key, source: scripts.get(key).source, revision: fingerprint(scripts.get(key).source), className: scripts.get(key).className }
102
+ : { path: key, code: "NOT_FOUND", message: "gone" },
103
+ ),
104
+ };
105
+ case "sync.revisions":
106
+ return {
107
+ items: params.paths.map((key) =>
108
+ scripts.has(key) ? { path: key, revision: fingerprint(scripts.get(key).source) } : { path: key, missing: true },
109
+ ),
110
+ };
111
+ case "sync.changes": {
112
+ const out = { token: "tracking", reset: params.token !== "tracking", dirty: [...dirty], structural: false };
113
+ dirty.clear();
114
+ return out;
115
+ }
116
+ case "sync.apply": {
117
+ const out = { writes: [], creates: [], deletes: [], moves: [], undoStep: "MCP sync" };
118
+ for (const item of params.creates ?? []) {
119
+ const target = `${chain(item)}.${item.name}`;
120
+ if (scripts.has(target)) out.creates.push({ ok: false, code: "EXISTS", message: "exists" });
121
+ else {
122
+ scripts.set(target, { className: item.className, source: item.source });
123
+ out.creates.push({ ok: true, path: target, revision: fingerprint(item.source) });
124
+ }
125
+ }
126
+ for (const item of params.deletes ?? []) {
127
+ const current = scripts.get(item.path);
128
+ if (!current || fingerprint(current.source) !== item.revision) out.deletes.push({ ok: false, path: item.path, code: "STALE_SCRIPT", message: "changed" });
129
+ else {
130
+ scripts.delete(item.path);
131
+ out.deletes.push({ ok: true, path: item.path });
132
+ }
133
+ }
134
+ for (const item of params.moves ?? []) {
135
+ const current = scripts.get(item.path);
136
+ if (!current || fingerprint(current.source) !== item.revision) out.moves.push({ ok: false, from: item.path, code: "STALE_SCRIPT", message: "changed" });
137
+ else {
138
+ const target = `${chain(item)}.${item.name}`;
139
+ scripts.delete(item.path);
140
+ scripts.set(target, current);
141
+ out.moves.push({ ok: true, from: item.path, path: target });
142
+ }
143
+ }
144
+ for (const item of params.writes ?? []) {
145
+ const current = scripts.get(item.path);
146
+ if (!current || fingerprint(current.source) !== item.revision) out.writes.push({ ok: false, path: item.path, code: "STALE_SCRIPT", message: "changed" });
147
+ else {
148
+ current.source = item.source;
149
+ // The plugin's editor event fires for sync's own write too.
150
+ dirty.add(item.path);
151
+ out.writes.push({ ok: true, path: item.path, revision: fingerprint(item.source) });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+ case "sync.classes": {
157
+ const spec = trees.get(params.path);
158
+ if (!spec) throw new ToolError("NOT_FOUND", `nothing at ${params.path}`);
159
+ const segments = params.path.split(".");
160
+ return {
161
+ path: params.path,
162
+ classes: [...classesIn(spec)].sort(),
163
+ chain: segments.map((name, index) => ({
164
+ name,
165
+ className: index === segments.length - 1 ? spec.className : index === 0 ? name : "Folder",
166
+ path: segments.slice(0, index + 1).join("."),
167
+ })),
168
+ };
169
+ }
170
+ case "sync.export": {
171
+ const spec = trees.get(params.path);
172
+ return { path: params.path, spec: structuredClone(spec), instances: classesIn(spec).size, scripts: 0, skipped: [] };
173
+ }
174
+ case "sync.build": {
175
+ const target = `${params.parent}.${params.spec.name ?? params.spec.className}`;
176
+ if (params.replaces && params.replaces !== target) trees.delete(params.replaces);
177
+ trees.set(target, untype(params.spec));
178
+ return { path: target, carried: [] };
179
+ }
180
+ case "sync.log":
181
+ logged.push(...params.lines);
182
+ return { logged: true };
183
+ case "sync.stop":
184
+ return { stopped: true };
185
+ default:
186
+ throw new Error(`fake Studio has no ${op}`);
187
+ }
188
+ },
189
+ };
190
+ return bridge;
191
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `sync` at scale, against a real Studio: thousands of scripts, same-named
4
+ * siblings, a source past the `.Source` limit -- timed, so a slowdown shows up
5
+ * as a number rather than as a watch that "feels laggy".
6
+ *
7
+ * Builds ServerStorage.__mcp_sync_scale and a temp folder, and removes both.
8
+ *
9
+ * Usage: node scripts/test-live-sync-scale.mjs [--scripts 2000] [--port 44755] [--studio-id ID]
10
+ */
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
13
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const args = process.argv.slice(2);
19
+ const flag = (name, fallback) => {
20
+ const at = args.indexOf(`--${name}`);
21
+ return at === -1 ? fallback : args[at + 1];
22
+ };
23
+ const COUNT = Number(flag("scripts", "2000"));
24
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const FIXTURE = "ServerStorage.__mcp_sync_scale";
26
+ const dir = mkdtempSync(join(tmpdir(), "rbx-sync-scale-"));
27
+ const file = (relative) => join(dir, "ServerStorage", "__mcp_sync_scale", ...relative.split("/"));
28
+ const studioId = flag("studio-id");
29
+
30
+ const client = new Client({ name: "test-live-sync-scale", version: "0" });
31
+ await client.connect(
32
+ new StdioClientTransport({
33
+ command: process.execPath,
34
+ args: [join(root, "dist", "index.js"), ...(flag("port") ? ["--port", flag("port")] : [])],
35
+ stderr: "inherit",
36
+ }),
37
+ );
38
+
39
+ let failures = 0;
40
+ const timings = [];
41
+ async function call(name, params = {}) {
42
+ const started = performance.now();
43
+ const result = await client.callTool({ name, arguments: { ...(studioId ? { studioId } : {}), ...params } }, undefined, { timeout: 600_000 });
44
+ const text = (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
45
+ return { text, isError: result.isError === true, ms: Math.round(performance.now() - started) };
46
+ }
47
+ function report(label, good, detail = "") {
48
+ if (!good) failures += 1;
49
+ process.stdout.write(`${good ? "ok " : "FAIL"} ${label.padEnd(48)} ${detail.replace(/\s+/g, " ").slice(0, good ? 100 : 400)}\n`);
50
+ }
51
+ async function timed(label, name, params, expect = /./) {
52
+ const reply = await call(name, params);
53
+ timings.push([label, reply.ms]);
54
+ report(label, !reply.isError && expect.test(reply.text), `${reply.ms}ms ${reply.text}`);
55
+ return reply;
56
+ }
57
+ const luau = async (source) => {
58
+ const reply = await call("execute_luau", { source });
59
+ if (reply.isError) throw new Error(reply.text);
60
+ return reply.text;
61
+ };
62
+ const sync = (op, extra = {}) => ({ op, dir, roots: [FIXTURE], ...extra });
63
+ const until = async (probe, seconds = 10) => {
64
+ const started = Date.now();
65
+ for (;;) {
66
+ if (await probe()) return Date.now() - started;
67
+ if (Date.now() - started > seconds * 1000) return -1;
68
+ await new Promise((resolve) => setTimeout(resolve, 50));
69
+ }
70
+ };
71
+
72
+ try {
73
+ // A place-shaped tree: 40 folders of modules with realistic bodies, two
74
+ // same-named siblings, and one script too long for `.Source`.
75
+ await luau(`
76
+ local old = game.ServerStorage:FindFirstChild("__mcp_sync_scale")
77
+ if old then old:Destroy() end
78
+ local fixture = Instance.new("Folder"); fixture.Name = "__mcp_sync_scale"
79
+ local body = string.rep("local value = math.random() * 100 -- padding to look like real code\\n", 30)
80
+ for f = 1, 40 do
81
+ local folder = Instance.new("Folder"); folder.Name = "Feature" .. f; folder.Parent = fixture
82
+ for s = 1, math.floor(${COUNT} / 40) do
83
+ local module = Instance.new("ModuleScript"); module.Name = "Module" .. s
84
+ module.Source = "-- " .. f .. "/" .. s .. "\\n" .. body .. "return {}\\n"
85
+ module.Parent = folder
86
+ end
87
+ end
88
+ local a = Instance.new("ModuleScript"); a.Name = "Dup"; a.Source = "return 'first'\\n"; a:SetAttribute("Which", "first"); a.Parent = fixture
89
+ local b = Instance.new("ModuleScript"); b.Name = "Dup"; b.Source = "return 'second'\\n"; b:SetAttribute("Which", "second"); b.Parent = fixture
90
+ fixture.Parent = game.ServerStorage
91
+ local big = Instance.new("ModuleScript"); big.Name = "Big"; big.Parent = fixture
92
+ game:GetService("ScriptEditorService"):UpdateSourceAsync(big, function() return string.rep("-- a long generated table row\\n", 12000) .. "return {}\\n" end)
93
+ return "ok"`);
94
+
95
+ await timed(`pull ${COUNT + 3} scripts`, "sync", sync("pull"), new RegExp(`${COUNT + 3} created on disk`));
96
+ await timed("no-op sync (nothing changed)", "sync", sync("sync"), /nothing to do/);
97
+ await timed("no-op sync again (revision cache warm)", "sync", sync("sync"), /nothing to do/);
98
+
99
+ writeFileSync(file("Feature7/Module3.luau"), "return 'edited'\n");
100
+ await timed("one file edited", "sync", sync("sync"), /1 written to Studio/);
101
+ const edited = await luau(`return game.ServerStorage.__mcp_sync_scale.Feature7.Module3.Source`);
102
+ report("the right script took it", edited.includes("edited"), edited);
103
+
104
+ // Same-named siblings: two files, each bound to its own script.
105
+ report("duplicate names get two files", existsSync(file("Dup.luau")) && existsSync(file("Dup~2.luau")));
106
+ const second = readFileSync(file("Dup~2.luau"), "utf8").includes("second") ? "Dup~2.luau" : "Dup.luau";
107
+ writeFileSync(file(second), "return 'second, edited'\n");
108
+ await timed("edit one of two same-named scripts", "sync", sync("sync"), /1 written to Studio/);
109
+ const which = await luau(`
110
+ for _, s in game.ServerStorage.__mcp_sync_scale:GetChildren() do
111
+ if s.Name == "Dup" and string.find(s.Source, "edited") then return s:GetAttribute("Which") end
112
+ end
113
+ return "none"`);
114
+ report("edit landed on the matching duplicate", which.includes("second"), which);
115
+
116
+ // Past the .Source limit: round trip through the editor path.
117
+ const big = readFileSync(file("Big.luau"), "utf8");
118
+ report("large script pulled whole", big.length > 300_000, `${big.length} chars`);
119
+ writeFileSync(file("Big.luau"), `${big}-- appended\n`);
120
+ await timed("push a 360KB script", "sync", sync("sync"), /1 written to Studio/);
121
+ const tail = await luau(`local s = game.ServerStorage.__mcp_sync_scale.Big.Source return string.sub(s, -12)`);
122
+ report("large script edit landed", tail.includes("appended"), tail);
123
+
124
+ // Watch latency at this size.
125
+ await timed("watch start", "sync", sync("watch"), /Watching|Already/);
126
+ writeFileSync(file("Feature20/Module5.luau"), "return 'watched'\n");
127
+ const toStudio = await until(async () => (await luau(`return game.ServerStorage.__mcp_sync_scale.Feature20.Module5.Source`)).includes("watched"));
128
+ timings.push(["watch: file -> Studio", toStudio]);
129
+ report("watch: file edit reached Studio", toStudio >= 0, `${toStudio}ms`);
130
+ await luau(`game:GetService("ScriptEditorService"):UpdateSourceAsync(game.ServerStorage.__mcp_sync_scale.Feature21.Module6, function() return "return 'from studio'\\n" end) return 1`);
131
+ const toDisk = await until(() => readFileSync(file("Feature21/Module6.luau"), "utf8").includes("from studio"));
132
+ timings.push(["watch: Studio -> file", toDisk]);
133
+ report("watch: Studio edit reached the file", toDisk >= 0, `${toDisk}ms`);
134
+ await new Promise((resolve) => setTimeout(resolve, 3_000));
135
+ const status = await call("sync", sync("status"));
136
+ report("watch settles: echoes skipped, not re-synced", /echo/.test(status.text), status.text.split("\n").slice(-1)[0]);
137
+ await call("sync", sync("stop"));
138
+ } catch (cause) {
139
+ report("run", false, String(cause?.stack ?? cause));
140
+ } finally {
141
+ await call("sync", { op: "stop", dir }).catch(() => undefined);
142
+ await luau(`local f = game.ServerStorage:FindFirstChild("__mcp_sync_scale") if f then f:Destroy() end return 1`).catch(() => undefined);
143
+ rmSync(dir, { recursive: true, force: true });
144
+ await client.close();
145
+ }
146
+
147
+ process.stdout.write("\ntimings\n");
148
+ for (const [label, ms] of timings) process.stdout.write(` ${label.padEnd(44)} ${ms}ms\n`);
149
+ process.stdout.write(failures === 0 ? "\nlive sync scale: ok\n" : `\nlive sync scale: ${failures} failure(s)\n`);
150
+ process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `sync` against a real Studio, through the real MCP layer.
4
+ *
5
+ * Everything happens inside ServerStorage.__mcp_live_sync and a temp folder;
6
+ * both are removed before exit, pass or fail. Checks the things an offline fake
7
+ * cannot: the plugin's scan, the editor write path, identity kept across a
8
+ * rename, build files round-tripping real property values, and watch mode
9
+ * reacting to real file and editor events.
10
+ *
11
+ * Usage: node scripts/test-live-sync.mjs [--port 44755] [--studio-id ID]
12
+ */
13
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
15
+ import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const args = process.argv.slice(2);
21
+ const flag = (name) => {
22
+ const at = args.indexOf(`--${name}`);
23
+ return at === -1 ? undefined : args[at + 1];
24
+ };
25
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
+ const FIXTURE = "ServerStorage.__mcp_live_sync";
27
+ const dir = mkdtempSync(join(tmpdir(), "rbx-live-sync-"));
28
+ const base = `ServerStorage/__mcp_live_sync`;
29
+ const file = (relative) => join(dir, ...`${base}/${relative}`.split("/"));
30
+
31
+ const client = new Client({ name: "test-live-sync", version: "0" });
32
+ await client.connect(
33
+ new StdioClientTransport({
34
+ command: process.execPath,
35
+ args: [join(root, "dist", "index.js"), ...(flag("port") ? ["--port", flag("port")] : [])],
36
+ stderr: "inherit",
37
+ }),
38
+ );
39
+
40
+ let failures = 0;
41
+ const studioId = flag("studio-id");
42
+
43
+ async function call(name, params = {}) {
44
+ const started = performance.now();
45
+ try {
46
+ const result = await client.callTool(
47
+ { name, arguments: { ...(studioId ? { studioId } : {}), ...params } },
48
+ undefined,
49
+ { timeout: 180_000 },
50
+ );
51
+ const text = (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n");
52
+ return { text, isError: result.isError === true, ms: Math.round(performance.now() - started) };
53
+ } catch (cause) {
54
+ return { text: cause.message, isError: true, ms: Math.round(performance.now() - started) };
55
+ }
56
+ }
57
+
58
+ function report(label, good, detail = "") {
59
+ if (!good) failures += 1;
60
+ process.stdout.write(`${good ? "ok " : "FAIL"} ${label.padEnd(52)} ${detail.replace(/\s+/g, " ").slice(0, good ? 90 : 400)}\n`);
61
+ }
62
+
63
+ async function check(label, name, params, expect = /./) {
64
+ const reply = await call(name, params);
65
+ report(label, !reply.isError && expect.test(reply.text), `${reply.ms}ms ${reply.text}`);
66
+ return reply.text;
67
+ }
68
+
69
+ /** Runs Luau in the edit session and returns what it returned, as text. */
70
+ async function luau(source) {
71
+ const reply = await call("execute_luau", { source });
72
+ if (reply.isError) throw new Error(reply.text);
73
+ return reply.text;
74
+ }
75
+
76
+ const sync = (op, extra = {}) => ({ op, dir, roots: [FIXTURE], ...extra });
77
+ // The first lines that differ, for a readable failure.
78
+ const diffOf = (before, after) => {
79
+ const a = before.split("\n");
80
+ const b = after.split("\n");
81
+ const at = a.findIndex((line, index) => line !== b[index]);
82
+ return `line ${at + 1}: ${a[at]?.trim()} -> ${b[at]?.trim()}`;
83
+ };
84
+ const read = (relative) => readFileSync(file(relative), "utf8");
85
+ const until = async (probe, seconds = 8) => {
86
+ const deadline = Date.now() + seconds * 1000;
87
+ for (;;) {
88
+ if (await probe()) return true;
89
+ if (Date.now() > deadline) return false;
90
+ await new Promise((resolve) => setTimeout(resolve, 250));
91
+ }
92
+ };
93
+
94
+ try {
95
+ await luau(`
96
+ local old = game.ServerStorage:FindFirstChild("__mcp_live_sync")
97
+ if old then old:Destroy() end
98
+ local fixture = Instance.new("Folder"); fixture.Name = "__mcp_live_sync"
99
+ local shared = Instance.new("Folder"); shared.Name = "Shared"; shared.Parent = fixture
100
+ local util = Instance.new("ModuleScript"); util.Name = "Util"; util.Source = "return 1\\n"; util:SetAttribute("Marker", "util"); util.Parent = shared
101
+ local main = Instance.new("Script"); main.Name = "Main"; main.Source = "print('main')\\n"; main.Parent = fixture
102
+ local helper = Instance.new("ModuleScript"); helper.Name = "Helper"; helper.Source = "return 'helper'\\n"; helper.Parent = main
103
+ local gui = Instance.new("ScreenGui"); gui.Name = "Gui"; gui.ResetOnSpawn = false; gui.Parent = fixture
104
+ local panel = Instance.new("Frame"); panel.Name = "Panel"; panel.Size = UDim2.new(0, 200, 0, 100); panel.BackgroundColor3 = Color3.new(1, 0.5, 0); panel.Parent = gui
105
+ local title = Instance.new("TextLabel"); title.Name = "Title"; title.Text = "Hello"; title.Parent = panel
106
+ local client = Instance.new("LocalScript"); client.Name = "GuiClient"; client.Source = "-- gui\\n"; client:SetAttribute("Marker", "gui"); client.Parent = panel
107
+ fixture.Parent = game.ServerStorage
108
+ return "ready"`);
109
+
110
+ // Pull: the tree lands on disk, Rojo-style.
111
+ await check("pull: first sync writes every script", "sync", sync("pull"), /4 created on disk/);
112
+ report("pull: script with children is a folder + init", existsSync(file("Main/init.server.luau")) && read("Main/Helper.luau") === "return 'helper'\n");
113
+ report("pull: LocalScript and ModuleScript extensions", existsSync(file("Gui/Panel/GuiClient.client.luau")) && read("Shared/Util.luau") === "return 1\n");
114
+ await check("status: nothing to do right after", "sync", sync("status"), /nothing to do/);
115
+
116
+ // Push: a file edit reaches the editor buffer.
117
+ writeFileSync(file("Shared/Util.luau"), "return 2\n");
118
+ await check("push: edited file", "sync", sync("push"), /1 written to Studio/);
119
+ await check("push: Studio has the new text", "script_read", { paths: [`${FIXTURE}.Shared.Util`] }, /return 2/);
120
+
121
+ // Pull: an editor edit reaches the file.
122
+ await check("studio edit through script_edit", "script_edit", { edits: [{ path: `${FIXTURE}.Main.Helper`, find: "'helper'", replace: "'studio'" }] });
123
+ await check("sync: Studio edit comes back", "sync", sync("sync"), /1 written to disk/);
124
+ report("file has the Studio edit", read("Main/Helper.luau") === "return 'studio'\n", read("Main/Helper.luau"));
125
+
126
+ // New file in a new folder: a new script, under a new Folder.
127
+ writeFileSync(file("Shared/New.luau"), "return 'new'\n");
128
+ await check("new file becomes a script", "sync", sync("sync"), /1 created in Studio/);
129
+ const created = await luau(`return game.ServerStorage.__mcp_live_sync.Shared.New.Source`);
130
+ report("new script has the file's text", created.includes("return 'new'"), created);
131
+
132
+ // Rename a file: the SAME script is renamed (its attribute survives).
133
+ renameSync(file("Shared/Util.luau"), file("Shared/Tools.luau"));
134
+ await check("renamed file moves the script", "sync", sync("sync"), /1 moved in Studio/);
135
+ const marker = await luau(`local s = game.ServerStorage.__mcp_live_sync.Shared:FindFirstChild("Tools"); return if s then s:GetAttribute("Marker") else "missing"`);
136
+ report("same instance: attribute kept across rename", marker.includes("util"), marker);
137
+
138
+ // Both sides changed: a conflict that touches neither side.
139
+ writeFileSync(file("Shared/Tools.luau"), "return 'disk'\n");
140
+ await luau(`game:GetService("ScriptEditorService"):UpdateSourceAsync(game.ServerStorage.__mcp_live_sync.Shared.Tools, function() return "return 'editor'\\n" end) return 1`);
141
+ await check("conflict is reported", "sync", sync("sync"), /Conflicts[\s\S]*Tools\.luau/);
142
+ report("conflict left the file alone", read("Shared/Tools.luau") === "return 'disk'\n");
143
+ await check("prefer disk settles it", "sync", sync("sync", { prefer: "disk" }), /1 written to Studio/);
144
+ await check("Studio took the disk side", "script_read", { paths: [`${FIXTURE}.Shared.Tools`] }, /return 'disk'/);
145
+
146
+ // Delete a file: the script is deleted (undoably).
147
+ unlinkSync(file("Shared/New.luau"));
148
+ await check("deleted file deletes the script", "sync", sync("sync"), /1 deleted in Studio[\s\S]*MCP sync/);
149
+ const gone = await luau(`return game.ServerStorage.__mcp_live_sync.Shared:FindFirstChild("New") == nil`);
150
+ report("script is gone", gone.includes("true"), gone);
151
+
152
+ // Build files: export, edit, rebuild -- scripts carried across.
153
+ await check("export a ScreenGui", "sync", { op: "export", dir, roots: [FIXTURE], paths: [`${FIXTURE}.Gui`] }, /Gui\.build\.json/);
154
+ const spec = JSON.parse(read("Gui.build.json"));
155
+ const title = spec.children[0].children.find((child) => child.name === "Title");
156
+ report("export holds non-default values only", spec.properties?.ResetOnSpawn === false && title?.properties?.Text === "Hello" && !("Visible" in (title.properties ?? {})), JSON.stringify(spec).slice(0, 300));
157
+ report("export leaves scripts out", !JSON.stringify(spec).includes("GuiClient"));
158
+ title.properties.Text = "Rebuilt";
159
+ writeFileSync(file("Gui.build.json"), `${JSON.stringify(spec, null, 2)}\n`);
160
+ await check("build rebuilds from the edited file", "sync", { op: "build", dir, roots: [FIXTURE] }, /Gui\.build\.json/);
161
+ const rebuilt = await luau(`
162
+ local gui = game.ServerStorage.__mcp_live_sync.Gui
163
+ local client = gui.Panel:FindFirstChild("GuiClient")
164
+ return gui.Panel.Title.Text .. "|" .. tostring(client and client:GetAttribute("Marker")) .. "|" .. tostring(gui.Panel.BackgroundColor3)`);
165
+ report("rebuilt text, same script, colour kept", rebuilt.includes("Rebuilt|gui|1, 0.5") || rebuilt.includes("Rebuilt|gui|1, 0.50"), rebuilt);
166
+
167
+ // Build files must round-trip every kind of value exactly, and references
168
+ // inside a rebuilt tree must point into the NEW tree, not the old one.
169
+ await luau(`
170
+ local fixture = game.ServerStorage.__mcp_live_sync
171
+ local model = Instance.new("Model"); model.Name = "Rig"
172
+ local a = Instance.new("Part"); a.Name = "A"; a.Anchored = true; a.Material = Enum.Material.Neon; a.BrickColor = BrickColor.new("Bright red")
173
+ a.CFrame = CFrame.new(1, 2, 3) * CFrame.Angles(0, math.rad(45), 0); a.Size = Vector3.new(2, 3, 4); a.Parent = model
174
+ local b = Instance.new("Part"); b.Name = "B"; b.Shape = Enum.PartType.Ball; b.Color = Color3.fromRGB(10, 200, 30); b.Position = Vector3.new(5, 2, 3); b.Parent = model
175
+ local weld = Instance.new("WeldConstraint"); weld.Name = "Weld"; weld.Part0 = a; weld.Part1 = b; weld.Parent = model
176
+ local pointer = Instance.new("ObjectValue"); pointer.Name = "Pointer"; pointer.Value = b; pointer.Parent = model
177
+ model.PrimaryPart = a
178
+ local gui = Instance.new("Frame"); gui.Name = "Card"; gui.Size = UDim2.new(0.5, 10, 0, 40); gui.Parent = model
179
+ local corner = Instance.new("UICorner"); corner.CornerRadius = UDim.new(0, 12); corner.Parent = gui
180
+ local gradient = Instance.new("UIGradient")
181
+ gradient.Color = ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) })
182
+ gradient.Transparency = NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 0.5) })
183
+ gradient.Rotation = 90; gradient.Parent = gui
184
+ local label = Instance.new("TextLabel"); label.Name = "Label"; label.Text = "Tag"; label.RichText = true
185
+ label.FontFace = Font.new("rbxasset://fonts/families/GothamSSm.json", Enum.FontWeight.Bold, Enum.FontStyle.Italic); label.Parent = gui
186
+ local image = Instance.new("ImageLabel"); image.Name = "Icon"; image.Image = "rbxassetid://123456"; image.ScaleType = Enum.ScaleType.Slice
187
+ image.SliceCenter = Rect.new(4, 4, 12, 12); image.Parent = gui
188
+ model.Parent = fixture
189
+ return 1`);
190
+ await check("export a tree of many value types", "sync", { op: "export", dir, roots: [FIXTURE], paths: [`${FIXTURE}.Rig`] }, /Rig\.build\.json/);
191
+ const exportedRig = read("Rig.build.json");
192
+ report(
193
+ "references export as paths into the tree",
194
+ exportedRig.includes(`"Part0": "${FIXTURE}.Rig.A"`) && exportedRig.includes(`"PrimaryPart": "${FIXTURE}.Rig.A"`),
195
+ exportedRig.slice(0, 200),
196
+ );
197
+ const missing = ["FontFace", "\"Size\": \"2, 3, 4\"", "\"Shape\"", '"Color"', '"Transparency"', "SliceCenter", "CornerRadius", "Material", "CFrame", "Part0", "Part1", "RichText", "rbxassetid://123456"]
198
+ .filter((needle) => !exportedRig.includes(needle));
199
+ report("every value type made it into the file", missing.length === 0, missing.join(", "));
200
+ // Rebuild from the untouched file, then export again: nothing may drift.
201
+ await check("rebuild from the same file", "sync", { op: "build", dir, roots: [FIXTURE], files: [`${base}/Rig.build.json`], prefer: "disk" }, /Rig\.build\.json/);
202
+ await check("export the rebuilt tree", "sync", { op: "export", dir, roots: [FIXTURE], paths: [`${FIXTURE}.Rig`] }, /Rig\.build\.json/);
203
+ const again = read("Rig.build.json");
204
+ report("round trip is lossless", again === exportedRig, again === exportedRig ? "" : diffOf(exportedRig, again));
205
+ const wired = await luau(`
206
+ local rig = game.ServerStorage.__mcp_live_sync.Rig
207
+ local weld = rig.Weld
208
+ return tostring(weld.Part0 == rig.A and weld.Part1 == rig.B and rig.Pointer.Value == rig.B and rig.PrimaryPart == rig.A)
209
+ .. "|" .. tostring(#game.ServerStorage.__mcp_live_sync:GetChildren())`);
210
+ report("references point into the new tree, old tree gone", wired.includes("true|4"), wired);
211
+
212
+ // Watch: both directions within seconds, with no call in between.
213
+ await check("watch starts", "sync", sync("watch"), /Watching/);
214
+ writeFileSync(file("Main/Helper.luau"), "return 'watched'\n");
215
+ const toStudio = await until(async () => (await luau(`return game.ServerStorage.__mcp_live_sync.Main.Helper.Source`)).includes("watched"));
216
+ report("watch: file edit reaches Studio", toStudio);
217
+ await luau(`game:GetService("ScriptEditorService"):UpdateSourceAsync(game.ServerStorage.__mcp_live_sync.Main.Helper, function() return "return 'from studio'\\n" end) return 1`);
218
+ const toDisk = await until(async () => read("Main/Helper.luau") === "return 'from studio'\n");
219
+ report("watch: Studio edit reaches the file", toDisk, read("Main/Helper.luau"));
220
+ await check("watch: status shows it running", "sync", sync("status"), /Watching since/);
221
+ await check("watch stops", "sync", sync("stop"), /Stopped watching/);
222
+ } catch (cause) {
223
+ report("run", false, String(cause?.stack ?? cause));
224
+ } finally {
225
+ await call("sync", { op: "stop", dir }).catch(() => undefined);
226
+ await luau(`local f = game.ServerStorage:FindFirstChild("__mcp_live_sync") if f then f:Destroy() end return 1`).catch(() => undefined);
227
+ rmSync(dir, { recursive: true, force: true });
228
+ await client.close();
229
+ }
230
+
231
+ process.stdout.write(failures === 0 ? "\nlive sync: ok\n" : `\nlive sync: ${failures} failure(s)\n`);
232
+ process.exit(failures === 0 ? 0 : 1);