@el4cteo/rbx-studio-mcp 0.2.9 → 0.3.1

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.
@@ -1,387 +1,461 @@
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
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
+ --[[
79
+ Each entry may carry its own line window, because the shape that actually
80
+ comes up is "line 40 of this one, line 300 of that one" -- and a single
81
+ range shared across the whole batch forced one call per script, which is
82
+ what batching this tool was for in the first place.
83
+
84
+ A plain string still means the whole file, or the batch-wide range when
85
+ one was given.
86
+ ]]
87
+ for _, entry in paths do
88
+ local windowed = typeof(entry) == "table"
89
+ local path = if windowed then entry.path else entry
90
+ local ok, resolved = pcall(resolveScript, path)
91
+ if not ok then
92
+ local err = resolved :: any
93
+ local reason = if typeof(err) == "table"
94
+ then (if err.hint then err.message .. " " .. err.hint else err.message)
95
+ else tostring(err)
96
+ table.insert(failures, string.format("%s: %s", tostring(path), reason))
97
+ continue
98
+ end
99
+
100
+ local target = resolved :: LuaSourceContainer
101
+ local lines = TextEdit.toLines(ScriptEdit.read(target))
102
+ local askedStart = if windowed and entry.startLine ~= nil
103
+ then tonumber(entry.startLine)
104
+ else tonumber(params.startLine)
105
+ local askedEnd = if windowed and entry.endLine ~= nil
106
+ then tonumber(entry.endLine)
107
+ else tonumber(params.endLine)
108
+ local startLine = math.max(askedStart or 1, 1)
109
+ local endLine = math.min(askedEnd or #lines, #lines)
110
+
111
+ local window: { string } = {}
112
+ table.move(lines, startLine, endLine, 1, window)
113
+
114
+ table.insert(items, {
115
+ path = Paths.of(target, memo),
116
+ className = target.ClassName,
117
+ lineCount = #lines,
118
+ startLine = startLine,
119
+ endLine = askedEnd,
120
+ source = table.concat(window, "\n"),
121
+ })
122
+ end
123
+
124
+ return { items = items, failures = failures }
125
+ end
126
+
127
+ --[[
128
+ Applies every edit in a batch, or none of them.
129
+
130
+ Atomicity here cannot come from ChangeHistoryService. A recording captures
131
+ instance changes, but `UpdateSourceAsync` goes through the script editor's own
132
+ per-document history, so cancelling a recording leaves an already-written
133
+ script edited -- measured, not assumed. Wrapping this in `Undo.record` would
134
+ therefore promise a rollback that never happens.
135
+
136
+ So the batch is a two-phase commit instead. Phase one reads and transforms
137
+ every script without writing anything, which is where essentially all failures
138
+ live: a missing `find`, an ambiguous one, a bad line range, conflicting edits.
139
+ Phase two writes the finished text. If a write fails there -- realistically
140
+ only a locked or package-owned script -- the scripts already written are
141
+ restored from the source captured in phase one.
142
+ ]]
143
+ function Scripts.edit(params: { [string]: any }): { [string]: any }
144
+ local edits = params.edits
145
+ if typeof(edits) ~= "table" or #edits == 0 then
146
+ Dispatch.fail(
147
+ "BAD_PARAMS",
148
+ "script_edit requires a non-empty `edits` array.",
149
+ "Each edit needs a `path` plus one of `find`/`replace`, "
150
+ .. "`startLine`/`replacement`, or `source`."
151
+ )
152
+ end
153
+
154
+ local order: { LuaSourceContainer } = {}
155
+ local grouped: { [Instance]: { TextEdit.Edit } } = {}
156
+ for position, edit in edits do
157
+ TextEdit.validate(edit, position)
158
+ local target = resolveScript(edit.path)
159
+ local bucket = grouped[target]
160
+ if not bucket then
161
+ bucket = {}
162
+ grouped[target] = bucket
163
+ table.insert(order, target)
164
+ end
165
+ table.insert(bucket :: { TextEdit.Edit }, edit)
166
+ end
167
+
168
+ -- Phase one: transform everything in memory. Any failure raises here, with
169
+ -- nothing written and the place untouched.
170
+ type Pending = { target: LuaSourceContainer, before: string, after: string }
171
+ local pending: { Pending } = {}
172
+ for _, target in order do
173
+ local before = ScriptEdit.read(target)
174
+ local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
175
+ table.insert(pending, { target = target, before = before, after = after })
176
+ end
177
+
178
+ -- Phase two: write. `written` is the compensation log for a mid-batch failure.
179
+ local written: { Pending } = {}
180
+ local memo: Paths.NameIndex = {}
181
+ local results: { { [string]: any } } = {}
182
+
183
+ for _, entry in pending do
184
+ -- Wrapped in a closure rather than passed to pcall directly: `write`
185
+ -- returns nothing, and pcall's typed signature expects a value back.
186
+ local ok, err = pcall(function()
187
+ ScriptEdit.write(entry.target, function()
188
+ return entry.after
189
+ end)
190
+ end)
191
+
192
+ if not ok then
193
+ for index = #written, 1, -1 do
194
+ local done = written[index]
195
+ -- Best effort: a restore that fails leaves that script edited, and
196
+ -- the original error still describes what actually went wrong.
197
+ pcall(function()
198
+ ScriptEdit.write(done.target, function()
199
+ return done.before
200
+ end)
201
+ end)
202
+ end
203
+ error(err, 0)
204
+ end
205
+
206
+ table.insert(written, entry)
207
+ table.insert(results, {
208
+ path = Paths.of(entry.target, memo),
209
+ className = entry.target.ClassName,
210
+ edits = #(grouped[entry.target] :: { TextEdit.Edit }),
211
+ lineCount = #TextEdit.toLines(entry.after),
212
+ lineDelta = #TextEdit.toLines(entry.after) - #TextEdit.toLines(entry.before),
213
+ })
214
+ end
215
+
216
+ return { items = results }
217
+ end
218
+
219
+ --[[
220
+ Searches script source. Matches come from the editor buffer, so text the user
221
+ has typed but not saved is found too -- which is the state an agent about to
222
+ edit the file actually needs to see.
223
+ ]]
224
+ function Scripts.grep(params: { [string]: any }): { [string]: any }
225
+ local pattern = params.pattern
226
+ if typeof(pattern) ~= "string" or pattern == "" then
227
+ Dispatch.fail("BAD_PARAMS", "script_grep requires a `pattern`.")
228
+ end
229
+
230
+ local root = if params.path then Paths.resolve(params.path) else game
231
+ local literal = params.literal == true
232
+ local ignoreCase = params.ignoreCase == true
233
+ local contextLines = math.clamp(tonumber(params.contextLines) or DEFAULT_CONTEXT, 0, 10)
234
+ local limit = math.min(tonumber(params.limit) or 100, MAX_MATCHES)
235
+ local offset = tonumber(params.offset) or 0
236
+ local classFilter = params.className
237
+
238
+ local needle = if ignoreCase then string.lower(pattern) else pattern
239
+
240
+ local targets: { LuaSourceContainer } = {}
241
+ for _, instance in root:GetDescendants() do
242
+ if not instance:IsA("LuaSourceContainer") then
243
+ continue
244
+ end
245
+ if root == game and Scope.isNoisy(instance) then
246
+ continue
247
+ end
248
+ if classFilter and not instance:IsA(classFilter) then
249
+ continue
250
+ end
251
+ table.insert(targets, instance)
252
+ end
253
+
254
+ if #targets > MAX_SCRIPTS then
255
+ Dispatch.fail(
256
+ "TOO_BROAD",
257
+ string.format("That search covers %d scripts, over the %d limit.", #targets, MAX_SCRIPTS),
258
+ "Narrow it with `path` to search one service or folder instead of the whole place."
259
+ )
260
+ end
261
+
262
+ local matches: { { [string]: any } } = {}
263
+ local total = 0
264
+ local memo: Paths.NameIndex = {}
265
+
266
+ for _, target in targets do
267
+ local lines = TextEdit.toLines(ScriptEdit.read(target))
268
+ local path: string? = nil
269
+
270
+ for number, line in lines do
271
+ local haystack = if ignoreCase then string.lower(line) else line
272
+ -- An invalid Lua pattern raises rather than simply not matching, so it
273
+ -- has to be caught and reported as a pattern problem, not a no-match.
274
+ local ok, from = pcall(string.find, haystack, needle, 1, literal)
275
+ if not ok then
276
+ Dispatch.fail(
277
+ "BAD_PATTERN",
278
+ string.format("%s is not a valid Lua pattern: %s", pattern, tostring(from)),
279
+ "Lua patterns escape with %, not backslash, and have no alternation. "
280
+ .. "Set `literal` to search for the text exactly as written."
281
+ )
282
+ end
283
+ if not from then
284
+ continue
285
+ end
286
+
287
+ total += 1
288
+ if total <= offset or #matches >= limit then
289
+ continue
290
+ end
291
+
292
+ if not path then
293
+ path = Paths.of(target, memo)
294
+ end
295
+
296
+ local entry: { [string]: any } = {
297
+ path = path,
298
+ line = number,
299
+ text = line,
300
+ }
301
+ if contextLines > 0 then
302
+ local before: { string } = {}
303
+ local after: { string } = {}
304
+ table.move(lines, math.max(number - contextLines, 1), number - 1, 1, before)
305
+ table.move(lines, number + 1, math.min(number + contextLines, #lines), 1, after)
306
+ entry.before = before
307
+ entry.after = after
308
+ end
309
+ table.insert(matches, entry)
310
+ end
311
+ end
312
+
313
+ return {
314
+ items = matches,
315
+ total = total,
316
+ offset = offset,
317
+ searched = #targets,
318
+ }
319
+ end
320
+
321
+ --[[
322
+ Creates scripts. Source is assigned directly here rather than through
323
+ `UpdateSourceAsync`: the instance does not exist yet, so nothing can have it
324
+ open in the editor and there is no buffer to conflict with. Every later edit
325
+ goes through the editor path.
326
+ ]]
327
+ --[[
328
+ Names the starter container a script was just parented into, or nil.
329
+
330
+ These four are copied into the player rather than run where they sit, so a
331
+ `Script` with a non-Legacy RunContext inside one runs BOTH in the original
332
+ and in every copy. Roblox does warn about it -- "will cause it to run
333
+ multiple times" -- but that warning is emitted by Studio itself and never
334
+ reaches `console`, so an agent following the "prefer Script with runContext
335
+ Client over LocalScript" advice writes a double-running script and is given
336
+ no way to find out.
337
+ ]]
338
+ local STARTER_CONTAINERS = {
339
+ "StarterGui",
340
+ "StarterPack",
341
+ "StarterPlayerScripts",
342
+ "StarterCharacterScripts",
343
+ }
344
+
345
+ local function starterContainer(instance: Instance): string?
346
+ for _, className in STARTER_CONTAINERS do
347
+ if instance:FindFirstAncestorOfClass(className :: any) then
348
+ return className
349
+ end
350
+ end
351
+ return nil
352
+ end
353
+
354
+ function Scripts.create(params: { [string]: any }): { [string]: any }
355
+ local requests = params.scripts
356
+ if typeof(requests) ~= "table" or #requests == 0 then
357
+ Dispatch.fail(
358
+ "BAD_PARAMS",
359
+ "script_create requires a non-empty `scripts` array.",
360
+ "Each entry needs `parent`, `name` and `className`."
361
+ )
362
+ end
363
+
364
+ for position, request in requests do
365
+ if typeof(request.name) ~= "string" or request.name == "" then
366
+ Dispatch.fail("BAD_PARAMS", string.format("scripts[%d] has no `name`.", position))
367
+ end
368
+ if not CREATABLE[request.className] then
369
+ Dispatch.fail(
370
+ "BAD_PARAMS",
371
+ string.format('scripts[%d] has className "%s".', position, tostring(request.className)),
372
+ "Use Script, LocalScript or ModuleScript. Prefer a Script with "
373
+ .. "runContext Client over LocalScript in new work -- except inside "
374
+ .. "StarterGui, StarterPack, StarterPlayerScripts or "
375
+ .. "StarterCharacterScripts, where LocalScript is still the right "
376
+ .. "class."
377
+ )
378
+ end
379
+ end
380
+
381
+ -- No shared path memo here: each creation changes its parent's children, so a
382
+ -- cached sibling grouping would go stale mid-batch and mis-number the paths.
383
+ local warnings: { string } = {}
384
+
385
+ local created, recorded = Undo.record("StudioMCP.ScriptCreate", "MCP create script", function()
386
+ local created: { { [string]: any } } = {}
387
+
388
+ for _, request in requests do
389
+ local parent = Paths.resolve(request.parent)
390
+ local instance = Instance.new(request.className) :: LuaSourceContainer
391
+
392
+ instance.Name = request.name
393
+ if typeof(request.source) == "string" then
394
+ (instance :: ScriptEdit.SourceContainer).Source = request.source
395
+ end
396
+
397
+ if typeof(request.runContext) == "string" and instance:IsA("Script") then
398
+ local ok, runContext = pcall(function()
399
+ return (Enum.RunContext :: any)[request.runContext]
400
+ end)
401
+ if not ok or runContext == nil then
402
+ Dispatch.fail(
403
+ "BAD_PARAMS",
404
+ string.format('"%s" is not a RunContext.', tostring(request.runContext)),
405
+ "Use Legacy, Server or Client."
406
+ )
407
+ end
408
+ instance.RunContext = runContext
409
+ end
410
+ if request.disabled == true and instance:IsA("BaseScript") then
411
+ instance.Disabled = true
412
+ end
413
+
414
+ instance.Parent = parent
415
+
416
+ if instance:IsA("Script") and instance.RunContext ~= Enum.RunContext.Legacy then
417
+ local container = starterContainer(instance)
418
+ if container then
419
+ table.insert(
420
+ warnings,
421
+ string.format(
422
+ '%s is a Script with RunContext %s inside %s. That container is '
423
+ .. "COPIED into each player, so the script runs once where it "
424
+ .. "sits and again in every copy. Make it a LocalScript "
425
+ .. "instead -- a Legacy Script there would not run at all. "
426
+ .. "Studio warns about this in its own Output, which `console` "
427
+ .. "cannot read.",
428
+ instance.Name,
429
+ instance.RunContext.Name,
430
+ container
431
+ )
432
+ )
433
+ end
434
+ end
435
+
436
+ table.insert(created, {
437
+ path = Paths.of(instance),
438
+ className = instance.ClassName,
439
+ })
440
+ end
441
+
442
+ return created
443
+ end)
444
+
445
+ return {
446
+ items = created,
447
+ undoStep = if recorded then "MCP create script" else nil,
448
+ warnings = if #warnings > 0 then warnings else nil,
449
+ }
450
+ end
451
+
452
+ function Scripts.register()
453
+ Dispatch.registerAll("script", {
454
+ read = Scripts.read,
455
+ edit = Scripts.edit,
456
+ grep = Scripts.grep,
457
+ create = Scripts.create,
458
+ })
459
+ end
460
+
461
+ return Scripts