@el4cteo/rbx-studio-mcp 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,205 @@
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
@@ -0,0 +1,387 @@
1
+ --!strict
2
+ --[[
3
+ Script reading, searching, editing and creation.
4
+
5
+ Everything here that writes goes through `ScriptEdit`, which routes the change
6
+ through `ScriptEditorService:UpdateSourceAsync` rather than assigning
7
+ `script.Source`. That is the difference between an edit the Studio editor
8
+ agrees with and one that silently loses whatever the user had typed but not
9
+ saved. Reads use the editor buffer for the same reason: handing an agent stale
10
+ source makes it "fix" changes the user just made.
11
+
12
+ Edits are also wrapped in a single `Undo` recording, so a batch across ten
13
+ scripts is one Ctrl+Z, and a batch that fails half way is rolled back rather
14
+ than left half applied.
15
+
16
+ The text manipulation itself lives in `TextEdit`, which has no Roblox
17
+ dependencies and is unit tested.
18
+ ]]
19
+
20
+ local Dispatch = require(script.Parent.Parent.Dispatch)
21
+ local Paths = require(script.Parent.Parent.Paths)
22
+ local Scope = require(script.Parent.Parent.Scope)
23
+ local ScriptEdit = require(script.Parent.Parent.ScriptEdit)
24
+ local TextEdit = require(script.Parent.Parent.TextEdit)
25
+ local Undo = require(script.Parent.Parent.Undo)
26
+
27
+ -- Reading every script's editor buffer is a service call each. A place with more
28
+ -- scripts than this is better served by narrowing `path` than by a slow grep
29
+ -- that blocks Studio's main thread.
30
+ local MAX_SCRIPTS = 3_000
31
+ local MAX_MATCHES = 500
32
+ local DEFAULT_CONTEXT = 0
33
+
34
+ local CREATABLE = {
35
+ Script = true,
36
+ LocalScript = true,
37
+ ModuleScript = true,
38
+ }
39
+
40
+ local Scripts = {}
41
+
42
+ --[[
43
+ Resolves a path and insists it holds Luau. Pointing a script tool at an
44
+ ordinary instance otherwise fails later with a confusing property error.
45
+ ]]
46
+ local function resolveScript(path: string): LuaSourceContainer
47
+ local instance = Paths.resolve(path)
48
+ if not ScriptEdit.isScript(instance) then
49
+ Dispatch.fail(
50
+ "NOT_A_SCRIPT",
51
+ string.format('"%s" is a %s, not a script.', path, instance.ClassName),
52
+ "Script tools accept Script, LocalScript and ModuleScript. Use `inspect` "
53
+ .. "for other instances, or `find` with className LuaSourceContainer to locate scripts."
54
+ )
55
+ end
56
+ return instance :: LuaSourceContainer
57
+ end
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
+ function Scripts.read(params: { [string]: any }): { [string]: any }
65
+ local paths = params.paths
66
+ if typeof(paths) ~= "table" or #paths == 0 then
67
+ Dispatch.fail(
68
+ "BAD_PARAMS",
69
+ "script_read requires a non-empty `paths` array.",
70
+ "Use `find` with className LuaSourceContainer to locate scripts."
71
+ )
72
+ end
73
+
74
+ local items: { { [string]: any } } = {}
75
+ local failures: { string } = {}
76
+ local memo: Paths.NameIndex = {}
77
+
78
+ for _, path in paths do
79
+ local ok, resolved = pcall(resolveScript, path)
80
+ if not ok then
81
+ local err = resolved :: any
82
+ local reason = if typeof(err) == "table"
83
+ then (if err.hint then err.message .. " " .. err.hint else err.message)
84
+ else tostring(err)
85
+ table.insert(failures, string.format("%s: %s", path, reason))
86
+ continue
87
+ end
88
+
89
+ local target = resolved :: LuaSourceContainer
90
+ local lines = TextEdit.toLines(ScriptEdit.read(target))
91
+ local startLine = math.max(tonumber(params.startLine) or 1, 1)
92
+ local endLine = math.min(tonumber(params.endLine) or #lines, #lines)
93
+
94
+ local window: { string } = {}
95
+ table.move(lines, startLine, endLine, 1, window)
96
+
97
+ table.insert(items, {
98
+ path = Paths.of(target, memo),
99
+ className = target.ClassName,
100
+ lineCount = #lines,
101
+ startLine = startLine,
102
+ source = table.concat(window, "\n"),
103
+ })
104
+ end
105
+
106
+ return { items = items, failures = failures }
107
+ end
108
+
109
+ --[[
110
+ Applies every edit in a batch, or none of them.
111
+
112
+ Atomicity here cannot come from ChangeHistoryService. A recording captures
113
+ instance changes, but `UpdateSourceAsync` goes through the script editor's own
114
+ per-document history, so cancelling a recording leaves an already-written
115
+ script edited -- measured, not assumed. Wrapping this in `Undo.record` would
116
+ therefore promise a rollback that never happens.
117
+
118
+ So the batch is a two-phase commit instead. Phase one reads and transforms
119
+ every script without writing anything, which is where essentially all failures
120
+ live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
121
+ Phase two writes the finished text. If a write fails there -- realistically
122
+ only a locked or package-owned script -- the scripts already written are
123
+ restored from the source captured in phase one.
124
+ ]]
125
+ function Scripts.edit(params: { [string]: any }): { [string]: any }
126
+ local edits = params.edits
127
+ if typeof(edits) ~= "table" or #edits == 0 then
128
+ Dispatch.fail(
129
+ "BAD_PARAMS",
130
+ "script_edit requires a non-empty `edits` array.",
131
+ "Each edit needs a `path` plus one of `find`/`replace`, "
132
+ .. "`startLine`/`replacement`, or `source`."
133
+ )
134
+ end
135
+
136
+ local order: { LuaSourceContainer } = {}
137
+ local grouped: { [Instance]: { TextEdit.Edit } } = {}
138
+ for position, edit in edits do
139
+ TextEdit.validate(edit, position)
140
+ local target = resolveScript(edit.path)
141
+ local bucket = grouped[target]
142
+ if not bucket then
143
+ bucket = {}
144
+ grouped[target] = bucket
145
+ table.insert(order, target)
146
+ end
147
+ table.insert(bucket :: { TextEdit.Edit }, edit)
148
+ end
149
+
150
+ -- Phase one: transform everything in memory. Any failure raises here, with
151
+ -- nothing written and the place untouched.
152
+ type Pending = { target: LuaSourceContainer, before: string, after: string }
153
+ local pending: { Pending } = {}
154
+ for _, target in order do
155
+ local before = ScriptEdit.read(target)
156
+ local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
157
+ table.insert(pending, { target = target, before = before, after = after })
158
+ end
159
+
160
+ -- Phase two: write. `written` is the compensation log for a mid-batch failure.
161
+ local written: { Pending } = {}
162
+ local memo: Paths.NameIndex = {}
163
+ local results: { { [string]: any } } = {}
164
+
165
+ for _, entry in pending do
166
+ -- Wrapped in a closure rather than passed to pcall directly: `write`
167
+ -- returns nothing, and pcall's typed signature expects a value back.
168
+ local ok, err = pcall(function()
169
+ ScriptEdit.write(entry.target, function()
170
+ return entry.after
171
+ end)
172
+ end)
173
+
174
+ if not ok then
175
+ for index = #written, 1, -1 do
176
+ local done = written[index]
177
+ -- Best effort: a restore that fails leaves that script edited, and
178
+ -- the original error still describes what actually went wrong.
179
+ pcall(function()
180
+ ScriptEdit.write(done.target, function()
181
+ return done.before
182
+ end)
183
+ end)
184
+ end
185
+ error(err, 0)
186
+ end
187
+
188
+ table.insert(written, entry)
189
+ table.insert(results, {
190
+ path = Paths.of(entry.target, memo),
191
+ className = entry.target.ClassName,
192
+ edits = #(grouped[entry.target] :: { TextEdit.Edit }),
193
+ lineCount = #TextEdit.toLines(entry.after),
194
+ lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
195
+ })
196
+ end
197
+
198
+ return { items = results }
199
+ end
200
+
201
+ --[[
202
+ Searches script source. Matches come from the editor buffer, so text the user
203
+ has typed but not saved is found too -- which is the state an agent about to
204
+ edit the file actually needs to see.
205
+ ]]
206
+ function Scripts.grep(params: { [string]: any }): { [string]: any }
207
+ local pattern = params.pattern
208
+ if typeof(pattern) ~= "string" or pattern == "" then
209
+ Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
210
+ end
211
+
212
+ local root = if params.path then Paths.resolve(params.path) else game
213
+ local literal = params.literal == true
214
+ local ignoreCase = params.ignoreCase == true
215
+ local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
216
+ local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
217
+ local offset = tonumber(params.offset) or 0
218
+ local classFilter = params.className
219
+
220
+ local needle = if ignoreCase then string.lower(pattern) else pattern
221
+
222
+ local targets: { LuaSourceContainer } = {}
223
+ for _, instance in root:GetDescendants() do
224
+ if not instance:IsA("LuaSourceContainer") then
225
+ continue
226
+ end
227
+ if root == game and Scope.isNoisy(instance) then
228
+ continue
229
+ end
230
+ if classFilter and not instance:IsA(classFilter) then
231
+ continue
232
+ end
233
+ table.insert(targets, instance)
234
+ end
235
+
236
+ if #targets > MAX_SCRIPTS then
237
+ Dispatch.fail(
238
+ "TOO_BROAD",
239
+ string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
240
+ "Narrow it with `path` to search one service or folder instead of the whole place."
241
+ )
242
+ end
243
+
244
+ local matches: { { [string]: any } } = {}
245
+ local total = 0
246
+ local memo: Paths.NameIndex = {}
247
+
248
+ for _, target in targets do
249
+ local lines = TextEdit.toLines(ScriptEdit.read(target))
250
+ local path: string? = nil
251
+
252
+ for number, line in lines do
253
+ local haystack = if ignoreCase then string.lower(line) else line
254
+ -- An invalid Lua pattern raises rather than simply not matching, so it
255
+ -- has to be caught and reported as a pattern problem, not a no-match.
256
+ local ok, from = pcall(string.find, haystack, needle, 1, literal)
257
+ if not ok then
258
+ Dispatch.fail(
259
+ "BAD_PATTERN",
260
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
261
+ "Lua patterns escape with %, not backslash, and have no alternation. "
262
+ .. "Set `literal` to search for the text exactly as written."
263
+ )
264
+ end
265
+ if not from then
266
+ continue
267
+ end
268
+
269
+ total += 1
270
+ if total <= offset or #matches >= limit then
271
+ continue
272
+ end
273
+
274
+ if not path then
275
+ path = Paths.of(target, memo)
276
+ end
277
+
278
+ local entry: { [string]: any } = {
279
+ path = path,
280
+ line = number,
281
+ text = line,
282
+ }
283
+ if contextLines > 0 then
284
+ local before: { string } = {}
285
+ local after: { string } = {}
286
+ table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
287
+ table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
288
+ entry.before = before
289
+ entry.after = after
290
+ end
291
+ table.insert(matches, entry)
292
+ end
293
+ end
294
+
295
+ return {
296
+ items = matches,
297
+ total = total,
298
+ offset = offset,
299
+ searched = #targets,
300
+ }
301
+ end
302
+
303
+ --[[
304
+ Creates scripts. Source is assigned directly here rather than through
305
+ `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
306
+ open in the editor and there is no buffer to conflict with. Every later edit
307
+ goes through the editor path.
308
+ ]]
309
+ function Scripts.create(params: { [string]: any }): { [string]: any }
310
+ local requests = params.scripts
311
+ if typeof(requests) ~= "table" or #requests == 0 then
312
+ Dispatch.fail(
313
+ "BAD_PARAMS",
314
+ "script_create requires a non-empty `scripts` array.",
315
+ "Each entry needs `parent`, `name` and `className`."
316
+ )
317
+ end
318
+
319
+ for position, request in requests do
320
+ if typeof(request.name) ~= "string" or request.name == "" then
321
+ Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position))
322
+ end
323
+ if not CREATABLE[request.className] then
324
+ Dispatch.fail(
325
+ "BAD_PARAMS",
326
+ string.format('scripts[%d] has className "%s".', position, tostring(request.className)),
327
+ "Use Script, LocalScript or ModuleScript. Prefer a Script with "
328
+ .. "runContext Client over LocalScript in new work."
329
+ )
330
+ end
331
+ end
332
+
333
+ -- No shared path memo here: each creation changes its parent's children, so a
334
+ -- cached sibling grouping would go stale mid-batch and mis-number the paths.
335
+ local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
336
+ local created: { { [string]: any } } = {}
337
+
338
+ for _, request in requests do
339
+ local parent = Paths.resolve(request.parent)
340
+ local instance = Instance.new(request.className) :: LuaSourceContainer
341
+
342
+ instance.Name = request.name
343
+ if typeof(request.source) == "string" then
344
+ (instance :: ScriptEdit.SourceContainer).Source = request.source
345
+ end
346
+
347
+ if typeof(request.runContext) == "string" and instance:IsA("Script") then
348
+ local ok, runContext = pcall(function()
349
+ return (Enum.RunContext :: any)[request.runContext]
350
+ end)
351
+ if not ok or runContext == nil then
352
+ Dispatch.fail(
353
+ "BAD_PARAMS",
354
+ string.format('"%s" is not a RunContext.', tostring(request.runContext)),
355
+ "Use Legacy, Server or Client."
356
+ )
357
+ end
358
+ instance.RunContext = runContext
359
+ end
360
+ if request.disabled == true and instance:IsA("BaseScript") then
361
+ instance.Disabled = true
362
+ end
363
+
364
+ instance.Parent = parent
365
+
366
+ table.insert(created, {
367
+ path = Paths.of(instance),
368
+ className = instance.ClassName,
369
+ })
370
+ end
371
+
372
+ return created
373
+ end)
374
+
375
+ return { items = created, undoStep = if recorded then "MCP create script" else nil }
376
+ end
377
+
378
+ function Scripts.register()
379
+ Dispatch.registerAll("script", {
380
+ read = Scripts.read,
381
+ edit = Scripts.edit,
382
+ grep = Scripts.grep,
383
+ create = Scripts.create,
384
+ })
385
+ end
386
+
387
+ return Scripts