@el4cteo/rbx-studio-mcp 0.7.1 → 0.7.5

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 (54) hide show
  1. package/dist/bridge/api.js +8 -8
  2. package/dist/bridge/api.js.map +1 -1
  3. package/dist/bridge/console.js +28 -0
  4. package/dist/bridge/console.js.map +1 -1
  5. package/dist/bridge/rpc.js +18 -18
  6. package/dist/bridge/rpc.js.map +1 -1
  7. package/dist/bridge/server.js +17 -21
  8. package/dist/bridge/server.js.map +1 -1
  9. package/dist/lib/opencloud.js +7 -7
  10. package/dist/lib/opencloud.js.map +1 -1
  11. package/dist/lib/tool.js +7 -0
  12. package/dist/lib/tool.js.map +1 -1
  13. package/dist/tools/debug.js +15 -4
  14. package/dist/tools/debug.js.map +1 -1
  15. package/dist/tools/exec.js +11 -6
  16. package/dist/tools/exec.js.map +1 -1
  17. package/dist/tools/input.js +7 -1
  18. package/dist/tools/input.js.map +1 -1
  19. package/dist/tools/playtest.js +11 -1
  20. package/dist/tools/playtest.js.map +1 -1
  21. package/dist/tools/scripts.js +55 -12
  22. package/dist/tools/scripts.js.map +1 -1
  23. package/dist/tools/world.js +0 -7
  24. package/dist/tools/world.js.map +1 -1
  25. package/package.json +75 -74
  26. package/plugin/src/ClientRelay.luau +88 -0
  27. package/plugin/src/Commands.luau +26 -0
  28. package/plugin/src/Config.luau +1 -1
  29. package/plugin/src/Console.luau +21 -21
  30. package/plugin/src/Emulation.luau +8 -8
  31. package/plugin/src/ExecRuntime.luau +161 -0
  32. package/plugin/src/Phrase.luau +3 -0
  33. package/plugin/src/RemoteTrace.luau +143 -0
  34. package/plugin/src/ScriptEdit.luau +14 -14
  35. package/plugin/src/Secret.luau +0 -9
  36. package/plugin/src/Transport.luau +58 -1
  37. package/plugin/src/Visuals.luau +8 -8
  38. package/plugin/src/handlers/Anim.luau +50 -50
  39. package/plugin/src/handlers/Character.luau +8 -8
  40. package/plugin/src/handlers/Debug.luau +574 -504
  41. package/plugin/src/handlers/Discover.luau +11 -11
  42. package/plugin/src/handlers/Exec.luau +36 -161
  43. package/plugin/src/handlers/Input.luau +54 -75
  44. package/plugin/src/handlers/Instances.luau +5 -5
  45. package/plugin/src/handlers/Playtest.luau +245 -205
  46. package/plugin/src/handlers/Scripts.luau +68 -19
  47. package/plugin/src/init.server.luau +946 -939
  48. package/scripts/test-bridge.mjs +40 -0
  49. package/scripts/test-console.mjs +25 -0
  50. package/scripts/test-failover.mjs +107 -99
  51. package/scripts/test-live.mjs +283 -0
  52. package/scripts/test-plugin.mjs +103 -85
  53. package/scripts/test-tools.mjs +151 -0
  54. package/scripts/test-transport.mjs +71 -58
@@ -1,205 +1,245 @@
1
- --!strict
2
- --[[
3
- Driving Studio's playtests.
4
-
5
- `StudioTestService`, not `RunService:Run`. Run/Pause/Stop are marked
6
- PluginSecurity and are permitted, and they do nothing: called from a real
7
- plugin handler -- not just through the loadstring sandbox, which was the first
8
- wrong explanation -- `Run()` returns successfully and leaves Studio in edit
9
- mode with nothing logged. StudioTestService is the service Roblox added for
10
- this, and it reaches further: Play mode with a character, and multiplayer
11
- tests, neither of which RunService offered at all.
12
-
13
- The Execute calls yield until the test ends -- specifically until something
14
- inside the session calls `EndTest`. That is the wrong shape for a request
15
- that should answer promptly, so the call is started on its own task and the
16
- reply describes the state it reached. The result, whenever the test finally
17
- produces one, is kept for a later `state` to collect.
18
- ]]
19
-
20
- local RunService = game:GetService("RunService")
21
- local StudioTestService = game:GetService("StudioTestService")
22
-
23
- local Dispatch = require(script.Parent.Parent.Dispatch)
24
-
25
- -- Long enough for Studio to actually enter the mode before the state is read
26
- -- back, so the reply describes what happened rather than what was asked for.
27
- local SETTLE_SECONDS = 1.5
28
-
29
- local Playtest = {}
30
-
31
- -- Outcome of the most recent test, which arrives long after the call that
32
- -- started it returned.
33
- local pending = false
34
- local lastResult: any = nil
35
- local lastError: string? = nil
36
- local startedAt: number? = nil
37
-
38
- local function service(): any
39
- return StudioTestService :: any
40
- end
41
-
42
- local function editModeActive(): boolean?
43
- local ok, value = pcall(function()
44
- return service().EditModeActive
45
- end)
46
- return if ok and typeof(value) == "boolean" then value else nil
47
- end
48
-
49
- local function state(): { [string]: any }
50
- return {
51
- isEdit = RunService:IsEdit(),
52
- isRunning = RunService:IsRunning(),
53
- isRunMode = RunService:IsRunMode(),
54
- editModeActive = editModeActive(),
55
- playerCount = #game:GetService("Players"):GetPlayers(),
56
- testPending = pending,
57
- -- Kept from the previous test rather than discarded, since whatever
58
- -- EndTest returned is the only thing a test session can hand back.
59
- lastResult = lastResult,
60
- lastError = lastError,
61
- runningForSeconds = if startedAt then math.floor(os.clock() - startedAt) else nil,
62
- }
63
- end
64
-
65
- --[[
66
- Starts a test without waiting for it to finish.
67
-
68
- Every failure here has to survive on its own thread: nothing is watching it
69
- by the time it fails, so the reason is stored for the next `state` instead of
70
- being raised into a caller that has already been answered.
71
- ]]
72
- local function launch(name: string, call: () -> any)
73
- pending = true
74
- lastResult = nil
75
- lastError = nil
76
- startedAt = os.clock()
77
-
78
- task.spawn(function()
79
- local ok, result = pcall(call)
80
- pending = false
81
- startedAt = nil
82
- if ok then
83
- lastResult = result
84
- else
85
- lastError = string.format("%s failed: %s", name, tostring(result))
86
- end
87
- end)
88
- end
89
-
90
- function Playtest.control(params: { [string]: any }): { [string]: any }
91
- local op = params.op
92
- local before = state()
93
-
94
- if op == "state" then
95
- return { changed = false, state = before }
96
- end
97
-
98
- --[[
99
- Ending a test has to happen inside it.
100
-
101
- `LeaveTest` reads like the plugin-side exit and is not: it refuses with
102
- "can only be called from the client DataModel of a running Studio test
103
- session", and a playtest client can never reach this bridge because Studio
104
- bars client sessions from making HTTP requests. So that route is closed for
105
- good, not merely awkward.
106
-
107
- `EndTest` works from the playtest's *server* session, which does connect --
108
- and its value travels back to whichever plugin called ExecutePlayModeAsync.
109
- The server tool routes a stop to that session; this op is what it calls.
110
- ]]
111
- if op == "endTest" then
112
- --[[
113
- Deferred, because EndTest destroys the DataModel this handler is
114
- running in -- including the connection carrying the reply. Called
115
- inline it does end the test and the caller sees only DISCONNECTED,
116
- which reads as a failed stop for something that worked perfectly.
117
-
118
- The reply goes out first and the teardown follows. Whether it
119
- actually ended is not taken on trust either way: the caller confirms
120
- against the editor session, which survives.
121
- ]]
122
- task.delay(0.25, function()
123
- pcall(function()
124
- service():EndTest(params.value or "stopped by studio-mcp")
125
- end)
126
- end)
127
- return { changed = true, deferred = true, state = before }
128
- end
129
-
130
- if op == "stop" then
131
- if before.isEdit and not before.isRunning and not pending then
132
- return { changed = false, reason = "no test is running", state = before }
133
- end
134
-
135
- -- Only reachable when the caller could not find a playtest session to end
136
- -- from. Stops a Run-mode session, which has no separate DataModel.
137
- pcall(function()
138
- RunService:Stop()
139
- end)
140
-
141
- task.wait(SETTLE_SECONDS)
142
- local after = state()
143
- return {
144
- changed = after.isRunning ~= before.isRunning or after.isEdit ~= before.isEdit,
145
- state = after,
146
- }
147
- end
148
-
149
- if pending then
150
- Dispatch.fail(
151
- "ALREADY_RUNNING",
152
- "A test is already running.",
153
- "Stop it first, or call with op 'state' to see how it is doing."
154
- )
155
- end
156
-
157
- -- ExecutePlayModeAsync rejects a nil argument outright -- "Argument 1 missing
158
- -- or nil" -- even though the parameter is a Variant, so an omitted `args`
159
- -- becomes an empty string rather than a failed launch.
160
- local args = if params.args == nil then "" else params.args
161
- if op == "play" then
162
- launch("ExecutePlayModeAsync", function()
163
- return service():ExecutePlayModeAsync(args)
164
- end)
165
- elseif op == "run" then
166
- launch("ExecuteRunModeAsync", function()
167
- return service():ExecuteRunModeAsync(args)
168
- end)
169
- elseif op == "multiplayer" then
170
- local players = tonumber(params.players) or 2
171
- if players < 1 or players > 8 then
172
- Dispatch.fail("BAD_PARAMS", "players must be between 1 and 8.")
173
- end
174
- launch("ExecuteMultiplayerTestAsync", function()
175
- return service():ExecuteMultiplayerTestAsync(players, args)
176
- end)
177
- else
178
- Dispatch.fail("BAD_PARAMS", string.format("unknown op %q", tostring(op)))
179
- end
180
-
181
- task.wait(SETTLE_SECONDS)
182
- local after = state()
183
-
184
- -- Whether the mode moved, not whether the call was accepted. Accepting and
185
- -- doing nothing is exactly how RunService:Run behaved here.
186
- local moved = after.isRunning ~= before.isRunning
187
- or after.isEdit ~= before.isEdit
188
- or after.editModeActive ~= before.editModeActive
189
-
190
- return {
191
- changed = moved or after.testPending,
192
- reason = if after.lastError then after.lastError
193
- elseif not moved and not after.testPending then "Studio accepted the call but the mode did not change"
194
- else nil,
195
- state = after,
196
- }
197
- end
198
-
199
- function Playtest.register()
200
- Dispatch.registerAll("playtest", {
201
- control = Playtest.control,
202
- })
203
- end
204
-
205
- return Playtest
1
+ --!strict
2
+ --[[
3
+ Driving Studio's playtests.
4
+
5
+ `StudioTestService`, not `RunService:Run`. Run/Pause/Stop are marked
6
+ PluginSecurity and are permitted, and they do nothing: called from a real
7
+ plugin handler -- not just through the loadstring sandbox, which was the first
8
+ wrong explanation -- `Run()` returns successfully and leaves Studio in edit
9
+ mode with nothing logged. StudioTestService is the service Roblox added for
10
+ this, and it reaches further: Play mode with a character, and multiplayer
11
+ tests, neither of which RunService offered at all.
12
+
13
+ The Execute calls yield until the test ends -- specifically until something
14
+ inside the session calls `EndTest`. That is the wrong shape for a request
15
+ that should answer promptly, so the call is started on its own task and the
16
+ reply describes the state it reached. The result, whenever the test finally
17
+ produces one, is kept for a later `state` to collect.
18
+ ]]
19
+
20
+ local RunService = game:GetService("RunService")
21
+ local StudioTestService = game:GetService("StudioTestService")
22
+
23
+ local Dispatch = require(script.Parent.Parent.Dispatch)
24
+
25
+ -- Long enough for Studio to actually enter the mode before the state is read
26
+ -- back, so the reply describes what happened rather than what was asked for.
27
+ local SETTLE_SECONDS = 1.5
28
+
29
+ local Playtest = {}
30
+
31
+ -- Supplied by the plugin entrypoint, which owns persistent plugin settings.
32
+ -- Read on every request so another panel context's choice is not cached here.
33
+ local readAllowed: () -> any = function() return nil end
34
+ local writeAllowed: (boolean) -> () = function() error("Playtest settings are not initialized") end
35
+ function Playtest.setup(read: () -> any, write: (boolean) -> ())
36
+ readAllowed, writeAllowed = read, write
37
+ end
38
+ function Playtest.isAllowed(): boolean
39
+ return readAllowed() ~= false
40
+ end
41
+ function Playtest.setAllowed(value: boolean)
42
+ writeAllowed(value)
43
+ end
44
+ local function requireAllowed()
45
+ if not Playtest.isAllowed() then
46
+ Dispatch.fail("PLAYTEST_DISABLED", "Playtests are disabled by the user.",
47
+ "Do not start or simulate a playtest. Continue using edit-mode tools and static inspection where possible.\nRun `playtests on` in the Studio MCP panel to re-enable playtesting.")
48
+ end
49
+ end
50
+
51
+ -- Outcome of the most recent test, which arrives long after the call that
52
+ -- started it returned.
53
+ local pending = false
54
+ local lastResult: any = nil
55
+ local lastError: string? = nil
56
+ local startedAt: number? = nil
57
+
58
+ local function service(): any
59
+ return StudioTestService :: any
60
+ end
61
+
62
+ local function editModeActive(): boolean?
63
+ local ok, value = pcall(function()
64
+ return service().EditModeActive
65
+ end)
66
+ return if ok and typeof(value) == "boolean" then value else nil
67
+ end
68
+
69
+ local function state(): { [string]: any }
70
+ return {
71
+ playtestsAllowed = Playtest.isAllowed(),
72
+ isEdit = RunService:IsEdit(),
73
+ isRunning = RunService:IsRunning(),
74
+ isRunMode = RunService:IsRunMode(),
75
+ editModeActive = editModeActive(),
76
+ playerCount = #game:GetService("Players"):GetPlayers(),
77
+ testPending = pending,
78
+ -- Kept from the previous test rather than discarded, since whatever
79
+ -- EndTest returned is the only thing a test session can hand back.
80
+ lastResult = lastResult,
81
+ lastError = lastError,
82
+ runningForSeconds = if startedAt then math.floor(os.clock() - startedAt) else nil,
83
+ }
84
+ end
85
+
86
+ --[[
87
+ Starts a test without waiting for it to finish.
88
+
89
+ Every failure here has to survive on its own thread: nothing is watching it
90
+ by the time it fails, so the reason is stored for the next `state` instead of
91
+ being raised into a caller that has already been answered.
92
+ ]]
93
+ local function launch(name: string, call: () -> any)
94
+ pending = true
95
+ lastResult = nil
96
+ lastError = nil
97
+ startedAt = os.clock()
98
+
99
+ task.spawn(function()
100
+ local ok, result = pcall(function()
101
+ -- Recheck after scheduling: OFF may have been entered while queued.
102
+ requireAllowed()
103
+ return call()
104
+ end)
105
+ pending = false
106
+ startedAt = nil
107
+ if ok then
108
+ lastResult = result
109
+ else
110
+ lastError = if typeof(result) == "table" and result.code == "PLAYTEST_DISABLED"
111
+ then result.code .. ": " .. result.message .. "\n" .. result.hint
112
+ else string.format("%s failed: %s", name, tostring(result))
113
+ end
114
+ end)
115
+ end
116
+
117
+ function Playtest.control(params: { [string]: any }): { [string]: any }
118
+ local op = params.op
119
+ if op == "play" or op == "run" or op == "multiplayer" then requireAllowed() end
120
+ local before = state()
121
+
122
+ if op == "state" then
123
+ return { changed = false, state = before }
124
+ end
125
+
126
+ --[[
127
+ Ending a test has to happen inside it.
128
+
129
+ `LeaveTest` reads like the plugin-side exit and is not: it refuses with
130
+ "can only be called from the client DataModel of a running Studio test
131
+ session", and a playtest client can never reach this bridge because Studio
132
+ bars client sessions from making HTTP requests. So that route is closed for
133
+ good, not merely awkward.
134
+
135
+ `EndTest` works from the playtest's *server* session, which does connect --
136
+ and its value travels back to whichever plugin called ExecutePlayModeAsync.
137
+ The server tool routes a stop to that session; this op is what it calls.
138
+ ]]
139
+ if op == "endTest" then
140
+ --[[
141
+ Deferred, because EndTest destroys the DataModel this handler is
142
+ running in -- including the connection carrying the reply. Called
143
+ inline it does end the test and the caller sees only DISCONNECTED,
144
+ which reads as a failed stop for something that worked perfectly.
145
+
146
+ The reply goes out first and the teardown follows. Whether it
147
+ actually ended is not taken on trust either way: the caller confirms
148
+ against the editor session, which survives.
149
+ ]]
150
+ task.delay(0.25, function()
151
+ pcall(function()
152
+ service():EndTest(params.value or "stopped by studio-mcp")
153
+ end)
154
+ end)
155
+ return { changed = true, deferred = true, state = before }
156
+ end
157
+
158
+ if op == "stop" then
159
+ if before.isEdit and not before.isRunning and not pending then
160
+ return { changed = false, reason = "no test is running", state = before }
161
+ end
162
+
163
+ -- Only reachable when the caller could not find a playtest session to end
164
+ -- from. Stops a Run-mode session, which has no separate DataModel.
165
+ pcall(function()
166
+ RunService:Stop()
167
+ end)
168
+
169
+ task.wait(SETTLE_SECONDS)
170
+ local after = state()
171
+ return {
172
+ changed = after.isRunning ~= before.isRunning or after.isEdit ~= before.isEdit,
173
+ state = after,
174
+ }
175
+ end
176
+
177
+ if pending then
178
+ Dispatch.fail(
179
+ "ALREADY_RUNNING",
180
+ "A test is already running.",
181
+ "Stop it first, or call with op 'state' to see how it is doing."
182
+ )
183
+ end
184
+
185
+ -- ExecutePlayModeAsync rejects a nil argument outright -- "Argument 1 missing
186
+ -- or nil" -- even though the parameter is a Variant, so an omitted `args`
187
+ -- becomes an empty string rather than a failed launch.
188
+ local args = if params.args == nil then "" else params.args
189
+ if op == "play" then
190
+ launch("ExecutePlayModeAsync", function()
191
+ return service():ExecutePlayModeAsync(args)
192
+ end)
193
+ elseif op == "run" then
194
+ launch("ExecuteRunModeAsync", function()
195
+ return service():ExecuteRunModeAsync(args)
196
+ end)
197
+ elseif op == "multiplayer" then
198
+ local players = tonumber(params.players) or 2
199
+ if players < 1 or players > 8 then
200
+ Dispatch.fail("BAD_PARAMS", "players must be between 1 and 8.")
201
+ end
202
+ launch("ExecuteMultiplayerTestAsync", function()
203
+ return service():ExecuteMultiplayerTestAsync(players, args)
204
+ end)
205
+ else
206
+ Dispatch.fail("BAD_PARAMS", string.format("unknown op %q", tostring(op)))
207
+ end
208
+
209
+ task.wait(SETTLE_SECONDS)
210
+ local after = state()
211
+
212
+ -- Whether the mode moved, not whether the call was accepted. Accepting and
213
+ -- doing nothing is exactly how RunService:Run behaved here.
214
+ local moved = after.isRunning ~= before.isRunning
215
+ or after.isEdit ~= before.isEdit
216
+ or after.editModeActive ~= before.editModeActive
217
+
218
+ return {
219
+ changed = moved or after.testPending,
220
+ reason = if after.lastError then after.lastError
221
+ elseif not moved and not after.testPending then "Studio accepted the call but the mode did not change"
222
+ else nil,
223
+ state = after,
224
+ }
225
+ end
226
+
227
+ -- Panel-only stop, also usable in the client view which cannot make HTTP calls.
228
+ -- No state-change listener is installed: manual Play stays available while OFF.
229
+ function Playtest.stopLocal()
230
+ if not RunService:IsEdit() and RunService:IsClient() and not RunService:IsServer() then
231
+ service():LeaveTest()
232
+ elseif not RunService:IsEdit() and not RunService:IsRunMode() then
233
+ Playtest.control({ op = "endTest" })
234
+ else
235
+ Playtest.control({ op = "stop" })
236
+ end
237
+ end
238
+
239
+ function Playtest.register()
240
+ Dispatch.registerAll("playtest", {
241
+ control = Playtest.control,
242
+ })
243
+ end
244
+
245
+ return Playtest
@@ -56,11 +56,6 @@ local function resolveScript(path: string): LuaSourceContainer
56
56
  return instance :: LuaSourceContainer
57
57
  end
58
58
 
59
- --[[
60
- Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
61
- inclusive, matching the numbers script_edit takes back, so a read and a write
62
- need no off-by-one conversion between them.
63
- ]]
64
59
  --[[
65
60
  A short fingerprint of a script's source, for detecting that it moved.
66
61
 
@@ -136,6 +131,11 @@ local function syncedFile(target: Instance): string?
136
131
  return name
137
132
  end
138
133
 
134
+ --[[
135
+ Reads source, optionally a line window. `startLine`/`endLine` are 1-based and
136
+ inclusive, matching the numbers script_edit takes back, so a read and a write
137
+ need no off-by-one conversion between them.
138
+ ]]
139
139
  function Scripts.read(params: { [string]: any }): { [string]: any }
140
140
  local paths = params.paths
141
141
  if typeof(paths) ~= "table" or #paths == 0 then
@@ -381,12 +381,45 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
381
381
  )
382
382
  end
383
383
 
384
+ local function badPattern(reason: any): never
385
+ Dispatch.fail(
386
+ "BAD_PATTERN",
387
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(reason)),
388
+ "Lua patterns escape with %, not backslash, and have no alternation. "
389
+ .. "Set `literal` to search for the text exactly as written."
390
+ )
391
+ end
392
+
393
+ --[[
394
+ One search over the whole file before splitting it into lines.
395
+
396
+ Most scripts in a place do not contain what is being looked for, and
397
+ splitting each into a line table only to find nothing was most of the cost
398
+ of a place-wide search. A pattern that matches no line cannot match the
399
+ whole file either -- except one anchored with `^` or `$`, which mean line
400
+ ends here but file ends there, so those skip this shortcut.
401
+ ]]
402
+ -- Any trailing `$` counts, escaped or not: skipping the shortcut costs speed,
403
+ -- taking it wrongly would hide matches.
404
+ local anchored = not literal and (string.sub(needle, 1, 1) == "^" or string.sub(needle, -1) == "$")
405
+
384
406
  local matches: { { [string]: any } } = {}
385
407
  local total = 0
386
408
  local memo: Paths.NameIndex = {}
387
409
 
388
410
  for _, target in targets do
389
- local lines = TextEdit.toLines(ScriptEdit.read(target))
411
+ local source = ScriptEdit.read(target)
412
+ if not anchored then
413
+ local ok, found = pcall(string.find, if ignoreCase then string.lower(source) else source, needle, 1, literal)
414
+ if not ok then
415
+ badPattern(found)
416
+ end
417
+ if not found then
418
+ continue
419
+ end
420
+ end
421
+
422
+ local lines = TextEdit.toLines(source)
390
423
  local path: string? = nil
391
424
 
392
425
  for number, line in lines do
@@ -395,12 +428,7 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
395
428
  -- has to be caught and reported as a pattern problem, not a no-match.
396
429
  local ok, from = pcall(string.find, haystack, needle, 1, literal)
397
430
  if not ok then
398
- Dispatch.fail(
399
- "BAD_PATTERN",
400
- string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
401
- "Lua patterns escape with %, not backslash, and have no alternation. "
402
- .. "Set `literal` to search for the text exactly as written."
403
- )
431
+ badPattern(from)
404
432
  end
405
433
  if not from then
406
434
  continue
@@ -440,12 +468,6 @@ function Scripts.grep(params: { [string]: any }): { [string]: any }
440
468
  }
441
469
  end
442
470
 
443
- --[[
444
- Creates scripts. Source is assigned directly here rather than through
445
- `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
446
- open in the editor and there is no buffer to conflict with. Every later edit
447
- goes through the editor path.
448
- ]]
449
471
  --[[
450
472
  Names the starter container a script was just parented into, or nil.
451
473
 
@@ -457,6 +479,14 @@ end
457
479
  Client over LocalScript" advice writes a double-running script and is given
458
480
  no way to find out.
459
481
  ]]
482
+ --[[
483
+ Assigning `Source` fails at 200,000 characters or more ("Provided string
484
+ length ... is greater than or equal to max length (200000)"), while
485
+ `UpdateSourceAsync` takes far more -- measured writing 279,000. Sources at
486
+ the limit are written through the editor instead of refused.
487
+ ]]
488
+ local SOURCE_PROPERTY_LIMIT = 200000
489
+
460
490
  local STARTER_CONTAINERS = {
461
491
  "StarterGui",
462
492
  "StarterPack",
@@ -473,6 +503,12 @@ local function starterContainer(instance: Instance): string?
473
503
  return nil
474
504
  end
475
505
 
506
+ --[[
507
+ Creates scripts. Source is assigned directly here rather than through
508
+ `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
509
+ open in the editor and there is no buffer to conflict with. Every later edit
510
+ goes through the editor path.
511
+ ]]
476
512
  function Scripts.create(params: { [string]: any }): { [string]: any }
477
513
  local requests = params.scripts
478
514
  if typeof(requests) ~= "table" or #requests == 0 then
@@ -503,6 +539,7 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
503
539
  -- No shared path memo here: each creation changes its parent's children, so a
504
540
  -- cached sibling grouping would go stale mid-batch and mis-number the paths.
505
541
  local warnings: { string } = {}
542
+ local oversized: { { target: LuaSourceContainer, source: string } } = {}
506
543
 
507
544
  local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
508
545
  local created: { { [string]: any } } = {}
@@ -513,7 +550,11 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
513
550
 
514
551
  instance.Name = request.name
515
552
  if typeof(request.source) == "string" then
516
- (instance :: ScriptEdit.SourceContainer).Source = request.source
553
+ if #request.source < SOURCE_PROPERTY_LIMIT then
554
+ (instance :: ScriptEdit.SourceContainer).Source = request.source
555
+ else
556
+ table.insert(oversized, { target = instance, source = request.source })
557
+ end
517
558
  end
518
559
 
519
560
  if typeof(request.runContext) == "string" and instance:IsA("Script") then
@@ -605,6 +646,14 @@ function Scripts.create(params: { [string]: any }): { [string]: any }
605
646
  return created
606
647
  end)
607
648
 
649
+ -- After the recording, because the editor write yields. Undoing the create
650
+ -- still removes these scripts whole, source and all.
651
+ for _, entry in oversized do
652
+ ScriptEdit.write(entry.target, function()
653
+ return entry.source
654
+ end)
655
+ end
656
+
608
657
  return {
609
658
  items = created,
610
659
  undoStep = if recorded then "MCP create script" else nil,