@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.
- package/README.md +24 -6
- package/dist/bridge/rpc.js +1 -1
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/apidump.js +75 -0
- package/dist/lib/apidump.js.map +1 -1
- package/dist/lib/errors.js +5 -4
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/format.js +92 -25
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/notices.js +29 -0
- package/dist/lib/notices.js.map +1 -0
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/sync.js +1141 -0
- package/dist/lib/sync.js.map +1 -0
- package/dist/lib/syncplan.js +338 -0
- package/dist/lib/syncplan.js.map +1 -0
- package/dist/lib/tool.js +9 -2
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/discover.js +6 -1
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +5 -0
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/instances.js +2 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +28 -3
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/screenshot.js +7 -3
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/scripts.js +110 -28
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/sync.js +169 -0
- package/dist/tools/sync.js.map +1 -0
- package/package.json +4 -4
- package/plugin/src/Commands.luau +72 -5
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +5 -0
- package/plugin/src/Dispatch.luau +131 -90
- package/plugin/src/ExecRuntime.luau +190 -168
- package/plugin/src/LogBuffer.luau +38 -9
- package/plugin/src/Paths.luau +42 -0
- package/plugin/src/Phrase.luau +41 -0
- package/plugin/src/Prompt.luau +23 -3
- package/plugin/src/ScriptEdit.luau +94 -8
- package/plugin/src/Serialize.luau +16 -1
- package/plugin/src/Transport.luau +5 -2
- package/plugin/src/Undo.luau +74 -10
- package/plugin/src/handlers/Capture.luau +818 -809
- package/plugin/src/handlers/Debug.luau +19 -16
- package/plugin/src/handlers/Discover.luau +31 -1
- package/plugin/src/handlers/Perf.luau +100 -21
- package/plugin/src/handlers/Scripts.luau +274 -90
- package/plugin/src/handlers/Sync.luau +968 -0
- package/plugin/src/init.server.luau +47 -2
- package/scripts/build.mjs +14 -0
- package/scripts/sync-fake.mjs +191 -0
- package/scripts/test-live-sync-scale.mjs +150 -0
- package/scripts/test-live-sync.mjs +232 -0
- package/scripts/test-live-tools.mjs +6 -1
- package/scripts/test-plugin.mjs +17 -0
- package/scripts/test-results.mjs +41 -0
- package/scripts/test-sync-more.mjs +228 -0
- package/scripts/test-sync.mjs +245 -0
- package/dist/tools/spatial.js +0 -135
- package/dist/tools/spatial.js.map +0 -1
- package/dist/tools/upload.js +0 -294
- 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
|
-
|
|
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);
|