@el4cteo/rbx-studio-mcp 0.8.3 → 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 (68) hide show
  1. package/README.md +24 -6
  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/Commands.luau +72 -5
  37. package/plugin/src/Config.luau +65 -65
  38. package/plugin/src/Console.luau +5 -0
  39. package/plugin/src/Dispatch.luau +131 -90
  40. package/plugin/src/ExecRuntime.luau +190 -168
  41. package/plugin/src/LogBuffer.luau +38 -9
  42. package/plugin/src/Paths.luau +42 -0
  43. package/plugin/src/Phrase.luau +41 -0
  44. package/plugin/src/Prompt.luau +23 -3
  45. package/plugin/src/ScriptEdit.luau +94 -8
  46. package/plugin/src/Serialize.luau +16 -1
  47. package/plugin/src/Transport.luau +5 -2
  48. package/plugin/src/Undo.luau +74 -10
  49. package/plugin/src/handlers/Capture.luau +818 -809
  50. package/plugin/src/handlers/Debug.luau +19 -16
  51. package/plugin/src/handlers/Discover.luau +31 -1
  52. package/plugin/src/handlers/Perf.luau +100 -21
  53. package/plugin/src/handlers/Scripts.luau +274 -90
  54. package/plugin/src/handlers/Sync.luau +968 -0
  55. package/plugin/src/init.server.luau +47 -2
  56. package/scripts/build.mjs +14 -0
  57. package/scripts/sync-fake.mjs +191 -0
  58. package/scripts/test-live-sync-scale.mjs +150 -0
  59. package/scripts/test-live-sync.mjs +232 -0
  60. package/scripts/test-live-tools.mjs +6 -1
  61. package/scripts/test-plugin.mjs +17 -0
  62. package/scripts/test-results.mjs +41 -0
  63. package/scripts/test-sync-more.mjs +228 -0
  64. package/scripts/test-sync.mjs +245 -0
  65. package/dist/tools/spatial.js +0 -135
  66. package/dist/tools/spatial.js.map +0 -1
  67. package/dist/tools/upload.js +0 -294
  68. 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"
@@ -65,6 +66,9 @@ local SETTING_WIDGET_OPEN = "widgetOpen"
65
66
  ]]
66
67
  local SETTING_AUTO_OPEN = "autoOpen"
67
68
  local SETTING_PLAYTESTS = "playtestsEnabled"
69
+ -- Whether sentences typed at the prompt are sent to an agent. Absent means off:
70
+ -- it only turns on when the user runs `chat on`.
71
+ local SETTING_CHAT = "chatEnabled"
68
72
  local SETTING_WIDGET_SIZE = "widgetSize"
69
73
  local SETTING_WIDGET_TRACE = "widgetTrace"
70
74
 
@@ -193,6 +197,7 @@ Audio.register()
193
197
  Exec.register()
194
198
  Viewport.register()
195
199
  Scripts.register()
200
+ Sync.register()
196
201
  Input.register()
197
202
  Device.register()
198
203
  Data.register()
@@ -261,6 +266,8 @@ local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
261
266
  left exactly as it was, so turning this back on restores the panel to however
262
267
  it was last left rather than to a default.
263
268
  ]]
269
+ local chat = plugin:GetSetting(SETTING_CHAT) == true
270
+
264
271
  local storedAutoOpen = plugin:GetSetting(SETTING_AUTO_OPEN)
265
272
  local autoOpen = if typeof(storedAutoOpen) == "boolean" then storedAutoOpen else true
266
273
  if not autoOpen then
@@ -560,12 +567,27 @@ Commands.setup({
560
567
  autoOpen = value
561
568
  plugin:SetSetting(SETTING_AUTO_OPEN, value)
562
569
  end,
570
+ chat = function()
571
+ return chat
572
+ end,
573
+ saveChat = function(value)
574
+ chat = value
575
+ plugin:SetSetting(SETTING_CHAT, value)
576
+ end,
563
577
  status = function()
564
578
  return currentStatus
565
579
  end,
566
580
  studioId = studioId,
567
581
  })
568
582
 
583
+ Console.setPromptChat(chat)
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
+
569
591
  -- The saved preset, now that there is something to paint. Done immediately
570
592
  -- after mounting and before the first line is logged, so nothing is ever drawn
571
593
  -- in the wrong palette.
@@ -741,7 +763,30 @@ end
741
763
  and any `*Async` call does -- and serialising them would let one slow edit
742
764
  stall every other request on the stream.
743
765
  ]]
744
- 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
745
790
  task.spawn(function()
746
791
  local startedAt = os.clock()
747
792
  --[[
@@ -764,7 +809,7 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
764
809
  ]]
765
810
  Console.beginCall(title, Phrase.kindOf(op))
766
811
 
767
- local result = Dispatch.invoke(id, op, params)
812
+ local result = Dispatch.invoke(id, op, params, deadlineMs)
768
813
  local milliseconds = (os.clock() - startedAt) * 1000
769
814
 
770
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);