rbxstudio-mcp 2.3.2 → 2.4.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 (175) hide show
  1. package/README.md +67 -14
  2. package/dist/__tests__/bridge-service.test.js +25 -13
  3. package/dist/__tests__/bridge-service.test.js.map +1 -1
  4. package/dist/__tests__/bridge-session.test.d.ts +2 -0
  5. package/dist/__tests__/bridge-session.test.d.ts.map +1 -0
  6. package/dist/__tests__/bridge-session.test.js +171 -0
  7. package/dist/__tests__/bridge-session.test.js.map +1 -0
  8. package/dist/__tests__/chunker.test.d.ts +2 -0
  9. package/dist/__tests__/chunker.test.d.ts.map +1 -0
  10. package/dist/__tests__/chunker.test.js +201 -0
  11. package/dist/__tests__/chunker.test.js.map +1 -0
  12. package/dist/__tests__/docs-core.test.d.ts +2 -0
  13. package/dist/__tests__/docs-core.test.d.ts.map +1 -0
  14. package/dist/__tests__/docs-core.test.js +137 -0
  15. package/dist/__tests__/docs-core.test.js.map +1 -0
  16. package/dist/__tests__/docs-fetcher.test.d.ts +2 -0
  17. package/dist/__tests__/docs-fetcher.test.d.ts.map +1 -0
  18. package/dist/__tests__/docs-fetcher.test.js +173 -0
  19. package/dist/__tests__/docs-fetcher.test.js.map +1 -0
  20. package/dist/__tests__/helpers.d.ts +8 -0
  21. package/dist/__tests__/helpers.d.ts.map +1 -0
  22. package/dist/__tests__/helpers.js +23 -0
  23. package/dist/__tests__/helpers.js.map +1 -0
  24. package/dist/__tests__/http-routes.test.d.ts +2 -0
  25. package/dist/__tests__/http-routes.test.d.ts.map +1 -0
  26. package/dist/__tests__/http-routes.test.js +233 -0
  27. package/dist/__tests__/http-routes.test.js.map +1 -0
  28. package/dist/__tests__/http-server.test.js +13 -6
  29. package/dist/__tests__/http-server.test.js.map +1 -1
  30. package/dist/__tests__/integration.test.js +9 -4
  31. package/dist/__tests__/integration.test.js.map +1 -1
  32. package/dist/__tests__/semantic-search.test.d.ts +2 -0
  33. package/dist/__tests__/semantic-search.test.d.ts.map +1 -0
  34. package/dist/__tests__/semantic-search.test.js +202 -0
  35. package/dist/__tests__/semantic-search.test.js.map +1 -0
  36. package/dist/__tests__/smoke.test.js +7 -3
  37. package/dist/__tests__/smoke.test.js.map +1 -1
  38. package/dist/__tests__/studio-client.test.d.ts +2 -0
  39. package/dist/__tests__/studio-client.test.d.ts.map +1 -0
  40. package/dist/__tests__/studio-client.test.js +25 -0
  41. package/dist/__tests__/studio-client.test.js.map +1 -0
  42. package/dist/__tests__/tool-nudges.test.d.ts +2 -0
  43. package/dist/__tests__/tool-nudges.test.d.ts.map +1 -0
  44. package/dist/__tests__/tool-nudges.test.js +60 -0
  45. package/dist/__tests__/tool-nudges.test.js.map +1 -0
  46. package/dist/__tests__/tool-registry.test.d.ts +2 -0
  47. package/dist/__tests__/tool-registry.test.d.ts.map +1 -0
  48. package/dist/__tests__/tool-registry.test.js +365 -0
  49. package/dist/__tests__/tool-registry.test.js.map +1 -0
  50. package/dist/__tests__/tools-bridge.test.d.ts +2 -0
  51. package/dist/__tests__/tools-bridge.test.d.ts.map +1 -0
  52. package/dist/__tests__/tools-bridge.test.js +396 -0
  53. package/dist/__tests__/tools-bridge.test.js.map +1 -0
  54. package/dist/__tests__/tools-docs.test.d.ts +2 -0
  55. package/dist/__tests__/tools-docs.test.d.ts.map +1 -0
  56. package/dist/__tests__/tools-docs.test.js +112 -0
  57. package/dist/__tests__/tools-docs.test.js.map +1 -0
  58. package/dist/__tests__/tools-guards.test.d.ts +2 -0
  59. package/dist/__tests__/tools-guards.test.d.ts.map +1 -0
  60. package/dist/__tests__/tools-guards.test.js +131 -0
  61. package/dist/__tests__/tools-guards.test.js.map +1 -0
  62. package/dist/__tests__/tools-runtime.test.d.ts +2 -0
  63. package/dist/__tests__/tools-runtime.test.d.ts.map +1 -0
  64. package/dist/__tests__/tools-runtime.test.js +214 -0
  65. package/dist/__tests__/tools-runtime.test.js.map +1 -0
  66. package/dist/__tests__/tools-visual.test.d.ts +2 -0
  67. package/dist/__tests__/tools-visual.test.d.ts.map +1 -0
  68. package/dist/__tests__/tools-visual.test.js +149 -0
  69. package/dist/__tests__/tools-visual.test.js.map +1 -0
  70. package/dist/bridge-service.d.ts +99 -12
  71. package/dist/bridge-service.d.ts.map +1 -1
  72. package/dist/bridge-service.js +238 -21
  73. package/dist/bridge-service.js.map +1 -1
  74. package/dist/docs/cache.d.ts +50 -0
  75. package/dist/docs/cache.d.ts.map +1 -0
  76. package/dist/docs/cache.js +123 -0
  77. package/dist/docs/cache.js.map +1 -0
  78. package/dist/docs/embeddings/chunker.d.ts +120 -0
  79. package/dist/docs/embeddings/chunker.d.ts.map +1 -0
  80. package/dist/docs/embeddings/chunker.js +395 -0
  81. package/dist/docs/embeddings/chunker.js.map +1 -0
  82. package/dist/docs/embeddings/embedder.d.ts +41 -0
  83. package/dist/docs/embeddings/embedder.d.ts.map +1 -0
  84. package/dist/docs/embeddings/embedder.js +113 -0
  85. package/dist/docs/embeddings/embedder.js.map +1 -0
  86. package/dist/docs/embeddings/index.d.ts +102 -0
  87. package/dist/docs/embeddings/index.d.ts.map +1 -0
  88. package/dist/docs/embeddings/index.js +250 -0
  89. package/dist/docs/embeddings/index.js.map +1 -0
  90. package/dist/docs/embeddings/manager.d.ts +68 -0
  91. package/dist/docs/embeddings/manager.d.ts.map +1 -0
  92. package/dist/docs/embeddings/manager.js +97 -0
  93. package/dist/docs/embeddings/manager.js.map +1 -0
  94. package/dist/docs/fetcher.d.ts +29 -0
  95. package/dist/docs/fetcher.d.ts.map +1 -0
  96. package/dist/docs/fetcher.js +244 -0
  97. package/dist/docs/fetcher.js.map +1 -0
  98. package/dist/docs/reference.d.ts +37 -0
  99. package/dist/docs/reference.d.ts.map +1 -0
  100. package/dist/docs/reference.js +108 -0
  101. package/dist/docs/reference.js.map +1 -0
  102. package/dist/docs/search.d.ts +194 -0
  103. package/dist/docs/search.d.ts.map +1 -0
  104. package/dist/docs/search.js +733 -0
  105. package/dist/docs/search.js.map +1 -0
  106. package/dist/http-server.d.ts.map +1 -1
  107. package/dist/http-server.js +52 -5
  108. package/dist/http-server.js.map +1 -1
  109. package/dist/index.d.ts +8 -9
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +35 -1035
  112. package/dist/index.js.map +1 -1
  113. package/dist/instructions.d.ts +15 -0
  114. package/dist/instructions.d.ts.map +1 -0
  115. package/dist/instructions.js +26 -0
  116. package/dist/instructions.js.map +1 -0
  117. package/dist/tools/defs/attributes.d.ts +6 -0
  118. package/dist/tools/defs/attributes.d.ts.map +1 -0
  119. package/dist/tools/defs/attributes.js +85 -0
  120. package/dist/tools/defs/attributes.js.map +1 -0
  121. package/dist/tools/defs/docs.d.ts +17 -0
  122. package/dist/tools/defs/docs.d.ts.map +1 -0
  123. package/dist/tools/defs/docs.js +151 -0
  124. package/dist/tools/defs/docs.js.map +1 -0
  125. package/dist/tools/defs/execute.d.ts +6 -0
  126. package/dist/tools/defs/execute.d.ts.map +1 -0
  127. package/dist/tools/defs/execute.js +21 -0
  128. package/dist/tools/defs/execute.js.map +1 -0
  129. package/dist/tools/defs/inspection.d.ts +7 -0
  130. package/dist/tools/defs/inspection.d.ts.map +1 -0
  131. package/dist/tools/defs/inspection.js +202 -0
  132. package/dist/tools/defs/inspection.js.map +1 -0
  133. package/dist/tools/defs/objects.d.ts +6 -0
  134. package/dist/tools/defs/objects.d.ts.map +1 -0
  135. package/dist/tools/defs/objects.js +111 -0
  136. package/dist/tools/defs/objects.js.map +1 -0
  137. package/dist/tools/defs/properties.d.ts +6 -0
  138. package/dist/tools/defs/properties.d.ts.map +1 -0
  139. package/dist/tools/defs/properties.js +71 -0
  140. package/dist/tools/defs/properties.js.map +1 -0
  141. package/dist/tools/defs/runtime.d.ts +6 -0
  142. package/dist/tools/defs/runtime.d.ts.map +1 -0
  143. package/dist/tools/defs/runtime.js +145 -0
  144. package/dist/tools/defs/runtime.js.map +1 -0
  145. package/dist/tools/defs/scripts.d.ts +18 -0
  146. package/dist/tools/defs/scripts.d.ts.map +1 -0
  147. package/dist/tools/defs/scripts.js +163 -0
  148. package/dist/tools/defs/scripts.js.map +1 -0
  149. package/dist/tools/defs/tags.d.ts +6 -0
  150. package/dist/tools/defs/tags.d.ts.map +1 -0
  151. package/dist/tools/defs/tags.js +74 -0
  152. package/dist/tools/defs/tags.js.map +1 -0
  153. package/dist/tools/defs/visual.d.ts +7 -0
  154. package/dist/tools/defs/visual.d.ts.map +1 -0
  155. package/dist/tools/defs/visual.js +208 -0
  156. package/dist/tools/defs/visual.js.map +1 -0
  157. package/dist/tools/index.d.ts +101 -25
  158. package/dist/tools/index.d.ts.map +1 -1
  159. package/dist/tools/index.js +580 -63
  160. package/dist/tools/index.js.map +1 -1
  161. package/dist/tools/nudges.d.ts +25 -0
  162. package/dist/tools/nudges.d.ts.map +1 -0
  163. package/dist/tools/nudges.js +34 -0
  164. package/dist/tools/nudges.js.map +1 -0
  165. package/dist/tools/registry.d.ts +20 -0
  166. package/dist/tools/registry.d.ts.map +1 -0
  167. package/dist/tools/registry.js +65 -0
  168. package/dist/tools/registry.js.map +1 -0
  169. package/dist/tools/types.d.ts +24 -0
  170. package/dist/tools/types.d.ts.map +1 -0
  171. package/dist/tools/types.js +2 -0
  172. package/dist/tools/types.js.map +1 -0
  173. package/package.json +7 -6
  174. package/studio-plugin/MCPPlugin.rbxmx +3 -238
  175. package/studio-plugin/plugin.luau +2041 -365
@@ -18,7 +18,7 @@ pcall(function()
18
18
  end)
19
19
 
20
20
  -- ============================================================================
21
- -- TEST SESSION COMPANION
21
+ -- TEST SESSION COMPANIONS
22
22
  --
23
23
  -- StudioTestService:EndTest() can only be called from the Server DataModel of
24
24
  -- a running test. The plugin lives in the Edit DataModel, so it cannot call
@@ -26,29 +26,54 @@ end)
26
26
  -- ServerScriptService BEFORE calling ExecutePlayModeAsync. When the test
27
27
  -- starts, that Script runs in the Server DataModel and:
28
28
  -- 1. Streams LogService.MessageOut to the bridge (/test-session/log)
29
- -- 2. Polls the bridge (/test-session/poll) for an "end" command
30
- -- 3. On "end", calls StudioTestService:EndTest() — which terminates the
29
+ -- 2. Polls the bridge (/test-session/poll) for commands
30
+ -- 3. On 'end', calls StudioTestService:EndTest() — which terminates the
31
31
  -- yielding ExecutePlayModeAsync call back in the plugin.
32
+ -- 4. On 'eval', loadstring()s + xpcall()s the supplied code on its own
33
+ -- task thread and POSTs the result to /test-session/eval-result.
34
+ --
35
+ -- We also inject a parallel CLIENT companion (LocalScript) into
36
+ -- StarterPlayer.StarterPlayerScripts. StarterPlayerScripts auto-clones into
37
+ -- each Player's PlayerScripts when they join, so every player in the test
38
+ -- gets their own companion copy. Clients identify themselves to the bridge
39
+ -- by userId so run_live_lua can target a specific player or pick any
40
+ -- available one.
32
41
  --
33
42
  -- Cleanup strategy:
34
- -- - Tagged with TEST_COMPANION_TAG so we can find orphans
43
+ -- - Both companions tagged with TEST_COMPANION_TAG so we can find orphans
35
44
  -- - Swept on plugin activate, on every playSolo start, and after the test
36
45
  -- ends (whether via stop_play, natural end, or user clicking Stop)
46
+ --
47
+ -- LLM-stomp prevention:
48
+ -- - Distinctive name + leading "(auto-generated, safe to delete)" comment
49
+ -- - CollectionService tag
50
+ -- - Hard refusals in destructive MCP tools (delete_object, edit_script,
51
+ -- set_script_source, set_property Disabled, move_instance) for any
52
+ -- instance bearing the tag or matching the name
37
53
  -- ============================================================================
38
54
  local TEST_COMPANION_NAME = "_MCPTestCompanion"
55
+ local TEST_COMPANION_NAME_CLIENT = "_MCPTestCompanion_Client"
39
56
  local TEST_COMPANION_TAG = "_MCPTestCompanion"
40
57
 
41
- -- The companion script source. Two placeholders are substituted at injection
42
- -- time: __MCP_SERVER_URL__ and __MCP_SESSION_ID__. We use distinctive
43
- -- placeholders (not %%...%% or {{...}}) to avoid collisions with Lua's own
44
- -- pattern syntax during gsub.
58
+ -- ----------------------------------------------------------------------------
59
+ -- SERVER companion template. Runs as a regular Script in ServerScriptService.
60
+ -- Placeholders __MCP_SERVER_URL__ and __MCP_SESSION_ID__ are substituted at
61
+ -- injection time. We use distinctive placeholders (not %%...%% or {{...}}) to
62
+ -- avoid collisions with Lua's own pattern syntax.
63
+ -- ----------------------------------------------------------------------------
45
64
  local COMPANION_SCRIPT_TEMPLATE = [[-- _MCPTestCompanion (auto-generated, safe to delete)
46
65
  -- Runs in the Server DataModel of a Studio test session started by the MCP
47
- -- plugin. Pipes output back to the local MCP bridge and listens for an "end"
48
- -- command to terminate the test cleanly via StudioTestService:EndTest().
66
+ -- plugin. Pipes output back to the local MCP bridge, listens for an "end"
67
+ -- command to terminate the test cleanly via StudioTestService:EndTest(), and
68
+ -- runs run_live_lua "eval" commands on the test's actual server.
69
+ --
70
+ -- DO NOT modify or delete while a play test is running — it will be cleaned
71
+ -- up automatically by the MCP plugin.
49
72
 
50
73
  local HttpService = game:GetService("HttpService")
51
74
  local LogService = game:GetService("LogService")
75
+ local Players = game:GetService("Players")
76
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
52
77
  local StudioTestService
53
78
  do
54
79
  local ok, svc = pcall(function() return game:GetService("StudioTestService") end)
@@ -66,6 +91,57 @@ local outputBuffer = {}
66
91
  local stopRequested = false
67
92
  local consecutiveFailures = 0
68
93
 
94
+ -- Probe loadstring availability ONCE up front. Result goes into the hello
95
+ -- payload so the bridge can refuse run_live_lua with a clear error if the
96
+ -- user hasn't enabled ServerScriptService.LoadStringEnabled.
97
+ local LOADSTRING_READY = false
98
+ do
99
+ local ok, fn = pcall(loadstring, "return 1")
100
+ if ok and type(fn) == "function" then
101
+ local execOk, execVal = pcall(fn)
102
+ if execOk and execVal == 1 then
103
+ LOADSTRING_READY = true
104
+ end
105
+ end
106
+ end
107
+
108
+ -- ---------------------------------------------------------------------------
109
+ -- CLIENT RELAY (server-side gateway)
110
+ -- ---------------------------------------------------------------------------
111
+ -- HttpService:RequestAsync is server-only in Roblox: LocalScripts can't make
112
+ -- HTTP requests. So the server companion is the only HTTP gateway, and the
113
+ -- client companion talks to us through these Remotes:
114
+ --
115
+ -- _MCPClientHello RemoteEvent client -> server, register/loadstring info
116
+ -- _MCPClientLogs RemoteEvent client -> server, batched LogService output
117
+ -- _MCPClientRelay RemoteFunction server -> client, dispatch run_live_lua eval
118
+ --
119
+ -- We keep these in ReplicatedStorage with Archivable=false so they don't get
120
+ -- saved into the place file, and they're cleaned up automatically when the
121
+ -- test session ends and the cloned DataModel is torn down.
122
+ -- ---------------------------------------------------------------------------
123
+ local clientRelay
124
+ local clientHello
125
+ local clientLogs
126
+ do
127
+ local function makeRemote(className, name)
128
+ local existing = ReplicatedStorage:FindFirstChild(name)
129
+ if existing and existing.ClassName == className then
130
+ return existing
131
+ elseif existing then
132
+ pcall(function() existing:Destroy() end)
133
+ end
134
+ local inst = Instance.new(className)
135
+ inst.Name = name
136
+ inst.Archivable = false
137
+ inst.Parent = ReplicatedStorage
138
+ return inst
139
+ end
140
+ clientRelay = makeRemote("RemoteFunction", "_MCPClientRelay")
141
+ clientHello = makeRemote("RemoteEvent", "_MCPClientHello")
142
+ clientLogs = makeRemote("RemoteEvent", "_MCPClientLogs")
143
+ end
144
+
69
145
  local function postJSON(path, body)
70
146
  local ok, response = pcall(function()
71
147
  return HttpService:RequestAsync({
@@ -84,6 +160,147 @@ local function postJSON(path, body)
84
160
  end
85
161
  end
86
162
 
163
+ -- ---------------------------------------------------------------------------
164
+ -- Client gateway: forward client-side Remote events into the bridge over HTTP.
165
+ -- Clients can't make HTTP requests themselves (HttpService is server-only), so
166
+ -- they fire RemoteEvents at us and we forward.
167
+ -- ---------------------------------------------------------------------------
168
+ clientHello.OnServerEvent:Connect(function(player, payload)
169
+ -- payload = { loadstringReady = bool }
170
+ -- Register the client with the bridge by issuing a single poll on its
171
+ -- behalf with a hello field. The bridge populates its clients map from
172
+ -- this poll, which is what run_live_lua's target='client' resolution
173
+ -- looks at.
174
+ local loadstringReady = false
175
+ if type(payload) == "table" and payload.loadstringReady ~= nil then
176
+ loadstringReady = payload.loadstringReady and true or false
177
+ end
178
+ task.spawn(function()
179
+ postJSON("/test-session/poll", {
180
+ sessionId = SESSION_ID,
181
+ target = "client",
182
+ userId = player.UserId,
183
+ playerName = player.Name,
184
+ hello = { loadstringReady = loadstringReady },
185
+ })
186
+ end)
187
+ end)
188
+
189
+ clientLogs.OnServerEvent:Connect(function(player, messages)
190
+ -- messages: array of { message, messageType, timestamp }
191
+ if type(messages) ~= "table" then return end
192
+ -- Tag each message with the player's name so multi-client tests are
193
+ -- legible in get_playtest_output.
194
+ local tagged = {}
195
+ for i, m in ipairs(messages) do
196
+ if type(m) == "table" then
197
+ tagged[i] = {
198
+ message = "[" .. player.Name .. "] " .. tostring(m.message or ""),
199
+ messageType = m.messageType or "MessageOutput",
200
+ timestamp = m.timestamp or os.time(),
201
+ }
202
+ end
203
+ end
204
+ if #tagged == 0 then return end
205
+ task.spawn(function()
206
+ postJSON("/test-session/log", {
207
+ sessionId = SESSION_ID,
208
+ source = "client",
209
+ messages = tagged,
210
+ })
211
+ end)
212
+ end)
213
+
214
+ -- ---------------------------------------------------------------------------
215
+ -- Result serializer (mirrors the plugin's executeLua serializer so MCP
216
+ -- clients see the same shapes whether they're running edit-mode execute_lua
217
+ -- or runtime run_live_lua). Depth + cycle guarded.
218
+ -- ---------------------------------------------------------------------------
219
+ local function serializeValue(value, depth, seen)
220
+ depth = depth or 0
221
+ seen = seen or {}
222
+ if depth > 8 then return "<max depth>" end
223
+
224
+ local t = typeof(value)
225
+ if value == nil then
226
+ return nil
227
+ elseif t == "string" then
228
+ -- Cap very long strings so a runaway print doesn't pollute the wire.
229
+ if #value > 65536 then
230
+ return string.sub(value, 1, 65536) .. "...<truncated>"
231
+ end
232
+ return value
233
+ elseif t == "number" then
234
+ -- JSON can't represent NaN/Infinity; coerce to string.
235
+ if value ~= value then return "NaN" end
236
+ if value == math.huge then return "Infinity" end
237
+ if value == -math.huge then return "-Infinity" end
238
+ return value
239
+ elseif t == "boolean" then
240
+ return value
241
+ elseif t == "Vector3" then
242
+ return { type = "Vector3", X = value.X, Y = value.Y, Z = value.Z }
243
+ elseif t == "Vector2" then
244
+ return { type = "Vector2", X = value.X, Y = value.Y }
245
+ elseif t == "CFrame" then
246
+ return { type = "CFrame", components = { value:GetComponents() } }
247
+ elseif t == "Color3" then
248
+ return { type = "Color3", R = value.R, G = value.G, B = value.B }
249
+ elseif t == "BrickColor" then
250
+ return { type = "BrickColor", Name = value.Name }
251
+ elseif t == "UDim" then
252
+ return { type = "UDim", Scale = value.Scale, Offset = value.Offset }
253
+ elseif t == "UDim2" then
254
+ return {
255
+ type = "UDim2",
256
+ X = { Scale = value.X.Scale, Offset = value.X.Offset },
257
+ Y = { Scale = value.Y.Scale, Offset = value.Y.Offset },
258
+ }
259
+ elseif t == "Rect" then
260
+ return { type = "Rect", Min = { X = value.Min.X, Y = value.Min.Y }, Max = { X = value.Max.X, Y = value.Max.Y } }
261
+ elseif t == "Ray" then
262
+ return {
263
+ type = "Ray",
264
+ Origin = { X = value.Origin.X, Y = value.Origin.Y, Z = value.Origin.Z },
265
+ Direction = { X = value.Direction.X, Y = value.Direction.Y, Z = value.Direction.Z },
266
+ }
267
+ elseif t == "NumberRange" then
268
+ return { type = "NumberRange", Min = value.Min, Max = value.Max }
269
+ elseif t == "EnumItem" then
270
+ return { type = "EnumItem", Name = tostring(value), Value = value.Value }
271
+ elseif t == "Instance" then
272
+ local fullName = "<unparented>"
273
+ pcall(function() fullName = value:GetFullName() end)
274
+ return { type = "Instance", ClassName = value.ClassName, Name = value.Name, Path = fullName }
275
+ elseif t == "table" then
276
+ if seen[value] then return "<cycle>" end
277
+ seen[value] = true
278
+ local result = {}
279
+ local count = 0
280
+ for k, v in pairs(value) do
281
+ count += 1
282
+ if count > 200 then
283
+ result["__truncated__"] = true
284
+ break
285
+ end
286
+ local keyStr = type(k) == "string" and k or tostring(k)
287
+ result[keyStr] = serializeValue(v, depth + 1, seen)
288
+ end
289
+ seen[value] = nil
290
+ return result
291
+ elseif t == "function" then
292
+ return "<function>"
293
+ elseif t == "thread" then
294
+ return "<thread>"
295
+ elseif t == "buffer" then
296
+ return "<buffer>"
297
+ else
298
+ -- Fallback: stringify whatever it is.
299
+ local ok, str = pcall(tostring, value)
300
+ return ok and str or ("<" .. t .. ">")
301
+ end
302
+ end
303
+
87
304
  LogService.MessageOut:Connect(function(message, messageType)
88
305
  if #outputBuffer >= MAX_BUFFER then
89
306
  -- Drop oldest to keep buffer bounded; we'd rather lose stale output
@@ -105,6 +322,7 @@ task.spawn(function()
105
322
  outputBuffer = {}
106
323
  local ok = postJSON("/test-session/log", {
107
324
  sessionId = SESSION_ID,
325
+ source = "server",
108
326
  messages = toSend,
109
327
  })
110
328
  if not ok then
@@ -120,10 +338,294 @@ task.spawn(function()
120
338
  end
121
339
  end)
122
340
 
341
+ -- ---------------------------------------------------------------------------
342
+ -- Eval handler. Compiles user code via loadstring, runs under xpcall on its
343
+ -- own task thread, captures LogService output during the call window, and
344
+ -- POSTs the result back to /test-session/eval-result. A task.delay watchdog
345
+ -- forces a 'timeout' reply if the user code yields too long.
346
+ -- ---------------------------------------------------------------------------
347
+ local function buildEvalEnv()
348
+ -- Mirror executeLua's sandbox so the same conveniences are available
349
+ -- on the in-test side. We expose game/services so the AI can mutate
350
+ -- live state, fire RemoteEvents, etc.
351
+ return setmetatable({
352
+ game = game,
353
+ workspace = workspace,
354
+ Players = Players,
355
+ Workspace = workspace,
356
+ ReplicatedStorage = ReplicatedStorage,
357
+ ServerStorage = game:GetService("ServerStorage"),
358
+ ServerScriptService = game:GetService("ServerScriptService"),
359
+ StarterGui = game:GetService("StarterGui"),
360
+ StarterPack = game:GetService("StarterPack"),
361
+ StarterPlayer = game:GetService("StarterPlayer"),
362
+ Lighting = game:GetService("Lighting"),
363
+ SoundService = game:GetService("SoundService"),
364
+ TweenService = game:GetService("TweenService"),
365
+ RunService = game:GetService("RunService"),
366
+ UserInputService = game:GetService("UserInputService"),
367
+ HttpService = HttpService,
368
+ CollectionService = game:GetService("CollectionService"),
369
+ PhysicsService = game:GetService("PhysicsService"),
370
+ PathfindingService = game:GetService("PathfindingService"),
371
+ TextService = game:GetService("TextService"),
372
+ MarketplaceService = game:GetService("MarketplaceService"),
373
+ TeleportService = game:GetService("TeleportService"),
374
+ DataStoreService = game:GetService("DataStoreService"),
375
+ MemoryStoreService = game:GetService("MemoryStoreService"),
376
+ MessagingService = game:GetService("MessagingService"),
377
+ BadgeService = game:GetService("BadgeService"),
378
+ Instance = Instance,
379
+ Vector3 = Vector3, Vector2 = Vector2, CFrame = CFrame, Color3 = Color3,
380
+ BrickColor = BrickColor, UDim = UDim, UDim2 = UDim2, Rect = Rect, Ray = Ray,
381
+ Region3 = Region3, NumberSequence = NumberSequence,
382
+ NumberSequenceKeypoint = NumberSequenceKeypoint, ColorSequence = ColorSequence,
383
+ ColorSequenceKeypoint = ColorSequenceKeypoint, NumberRange = NumberRange,
384
+ TweenInfo = TweenInfo, Font = Font, Enum = Enum,
385
+ print = print, warn = warn, error = error, assert = assert,
386
+ type = type, typeof = typeof, tostring = tostring, tonumber = tonumber,
387
+ pairs = pairs, ipairs = ipairs, next = next, select = select, unpack = unpack,
388
+ pcall = pcall, xpcall = xpcall,
389
+ rawget = rawget, rawset = rawset, rawequal = rawequal,
390
+ setmetatable = setmetatable, getmetatable = getmetatable, newproxy = newproxy,
391
+ table = table, string = string, math = math,
392
+ os = { time = os.time, date = os.date, difftime = os.difftime, clock = os.clock },
393
+ coroutine = coroutine, bit32 = bit32, utf8 = utf8, buffer = buffer,
394
+ task = task, wait = task.wait, delay = task.delay, spawn = task.spawn, defer = task.defer,
395
+ loadstring = loadstring,
396
+ require = require,
397
+ debug = debug,
398
+ tick = tick,
399
+ }, { __index = _G })
400
+ end
401
+
402
+ local function postEvalResult(replyId, payload)
403
+ payload.sessionId = SESSION_ID
404
+ payload.replyId = replyId
405
+ postJSON("/test-session/eval-result", payload)
406
+ end
407
+
408
+ local function handleEvalCommand(args)
409
+ if not args or type(args.replyId) ~= "string" or type(args.code) ~= "string" then
410
+ return -- malformed; bridge will time out, no point replying without replyId
411
+ end
412
+ local replyId = args.replyId
413
+ local code = args.code
414
+ local timeoutMs = type(args.timeoutMs) == "number" and args.timeoutMs or 5000
415
+ local captureLogs = args.captureLogs ~= false -- default true
416
+
417
+ -- Run the eval on its own task so this command poller can keep going
418
+ -- (a slow eval shouldn't block log streaming or further commands).
419
+ task.spawn(function()
420
+ local startedAt = os.clock()
421
+ local replied = false
422
+ local function safeReply(payload)
423
+ if replied then return end
424
+ replied = true
425
+ payload.durationMs = math.floor((os.clock() - startedAt) * 1000)
426
+ postEvalResult(replyId, payload)
427
+ end
428
+
429
+ -- ----------------------------------------------------------------
430
+ -- forwardTo path: run_live_lua with target='client' enqueues on the
431
+ -- server queue with forwardTo metadata; we dispatch via InvokeClient
432
+ -- through _MCPClientRelay. The client's OnClientInvoke handler does
433
+ -- the actual eval and returns the payload.
434
+ -- ----------------------------------------------------------------
435
+ if type(args.forwardTo) == "table" then
436
+ local targetUserId = args.forwardTo.userId
437
+ local targetName = args.forwardTo.playerName
438
+ local target = nil
439
+ if type(targetUserId) == "number" then
440
+ for _, p in ipairs(Players:GetPlayers()) do
441
+ if p.UserId == targetUserId then target = p; break end
442
+ end
443
+ end
444
+ if not target and type(targetName) == "string" and #targetName > 0 then
445
+ target = Players:FindFirstChild(targetName)
446
+ end
447
+ if not target then
448
+ safeReply({
449
+ ok = false,
450
+ errorType = "no_such_player",
451
+ error = string.format(
452
+ "Could not resolve target client (userId=%s, playerName=%s) — they may have left the test.",
453
+ tostring(targetUserId), tostring(targetName)
454
+ ),
455
+ })
456
+ return
457
+ end
458
+
459
+ -- Server-side watchdog: InvokeClient blocks until the client
460
+ -- returns, but if the client hangs we still want a bounded reply.
461
+ task.delay(timeoutMs / 1000 + 1, function()
462
+ if replied then return end
463
+ safeReply({
464
+ ok = false,
465
+ errorType = "timeout",
466
+ error = string.format(
467
+ "Client eval did not return within %d ms (server-side watchdog).",
468
+ timeoutMs
469
+ ),
470
+ })
471
+ end)
472
+
473
+ local invokeOk, payload = pcall(function()
474
+ return clientRelay:InvokeClient(target, {
475
+ replyId = replyId,
476
+ code = code,
477
+ timeoutMs = timeoutMs,
478
+ captureLogs = captureLogs,
479
+ })
480
+ end)
481
+ if not invokeOk then
482
+ safeReply({
483
+ ok = false,
484
+ errorType = "client_disconnected",
485
+ error = "InvokeClient failed (client likely left the test): " .. tostring(payload),
486
+ })
487
+ return
488
+ end
489
+ if type(payload) ~= "table" then
490
+ safeReply({
491
+ ok = false,
492
+ errorType = "client_protocol_error",
493
+ error = "Client returned a non-table payload: " .. typeof(payload),
494
+ })
495
+ return
496
+ end
497
+ safeReply(payload)
498
+ return
499
+ end
500
+
501
+ if not LOADSTRING_READY then
502
+ safeReply({
503
+ ok = false,
504
+ errorType = "loadstring_disabled",
505
+ error = "loadstring is disabled. Enable ServerScriptService.LoadStringEnabled in the Properties pane (it has the NotScriptable tag so it cannot be flipped from code) and start a new play test.",
506
+ })
507
+ return
508
+ end
509
+
510
+ -- Capture logs scoped to this eval call. We collect into a private
511
+ -- buffer via a fresh LogService connection, then disconnect once
512
+ -- we've replied.
513
+ local localLogs = {}
514
+ local logConn = nil
515
+ if captureLogs then
516
+ logConn = LogService.MessageOut:Connect(function(message, messageType)
517
+ if #localLogs >= 200 then return end
518
+ table.insert(localLogs, {
519
+ message = tostring(message),
520
+ messageType = messageType.Name,
521
+ timestamp = os.time(),
522
+ })
523
+ end)
524
+ end
525
+
526
+ local function detachLogs()
527
+ if logConn then
528
+ pcall(function() logConn:Disconnect() end)
529
+ logConn = nil
530
+ end
531
+ end
532
+
533
+ -- Compile. NOTE: loadstring returns (nil, errorMessage) on syntax
534
+ -- failure rather than throwing — wrapping in pcall would discard the
535
+ -- error message into pcall's third return slot and leave fnOrErr=nil.
536
+ -- Calling loadstring directly captures both function and error cleanly.
537
+ local fnOrErr, compileErr = loadstring(code, "run_live_lua")
538
+ if type(fnOrErr) ~= "function" then
539
+ detachLogs()
540
+ safeReply({
541
+ ok = false,
542
+ errorType = "compile_error",
543
+ error = "Failed to compile: " .. tostring(compileErr or fnOrErr or "<unknown>"),
544
+ logs = captureLogs and localLogs or nil,
545
+ })
546
+ return
547
+ end
548
+
549
+ setfenv(fnOrErr, buildEvalEnv())
550
+
551
+ -- Watchdog: if the user code yields longer than timeoutMs, reply
552
+ -- with a synthetic timeout. The user thread will keep running
553
+ -- (we can't kill a yielded thread cleanly) but the AI gets a
554
+ -- bounded response.
555
+ task.delay(timeoutMs / 1000, function()
556
+ if replied then return end
557
+ detachLogs()
558
+ safeReply({
559
+ ok = false,
560
+ errorType = "timeout",
561
+ error = string.format("Eval did not return within %d ms (the spawned thread may still be running in the test).", timeoutMs),
562
+ logs = captureLogs and localLogs or nil,
563
+ })
564
+ end)
565
+
566
+ -- Execute. Pack multi-return into an array so the bridge can
567
+ -- preserve `return a, b, c` semantics.
568
+ local results = table.pack(xpcall(fnOrErr, function(err)
569
+ return { error = tostring(err), traceback = debug.traceback(nil, 2) }
570
+ end))
571
+ local execOk = results[1]
572
+ if replied then
573
+ -- Watchdog already responded.
574
+ detachLogs()
575
+ return
576
+ end
577
+
578
+ -- LogService.MessageOut events are deferred and need TWO Heartbeats
579
+ -- to actually invoke listeners (verified empirically — one task.wait
580
+ -- isn't enough). Yield twice so prints fired during the eval land
581
+ -- in localLogs before we detach + reply. Total cost ~32ms.
582
+ if captureLogs then
583
+ task.wait()
584
+ task.wait()
585
+ end
586
+
587
+ if not execOk then
588
+ detachLogs()
589
+ local err = results[2] or {}
590
+ safeReply({
591
+ ok = false,
592
+ errorType = "runtime_error",
593
+ error = (type(err) == "table" and err.error) or tostring(err),
594
+ traceback = (type(err) == "table") and err.traceback or nil,
595
+ logs = captureLogs and localLogs or nil,
596
+ })
597
+ return
598
+ end
599
+
600
+ -- Pack remaining values (results[2..n]) as the multi-return array.
601
+ local values = {}
602
+ for i = 2, results.n do
603
+ values[i - 1] = serializeValue(results[i], 0, {})
604
+ end
605
+
606
+ detachLogs()
607
+ safeReply({
608
+ ok = true,
609
+ values = values,
610
+ logs = captureLogs and localLogs or nil,
611
+ })
612
+ end)
613
+ end
614
+
123
615
  -- Command poller
124
616
  task.spawn(function()
617
+ -- First poll includes hello so the bridge can record loadstring
618
+ -- readiness for later run_live_lua callers.
619
+ local helloSent = false
125
620
  while not stopRequested and consecutiveFailures < MAX_CONSECUTIVE_FAILURES do
126
- local ok, response = postJSON("/test-session/poll", { sessionId = SESSION_ID })
621
+ local pollBody = { sessionId = SESSION_ID, target = "server" }
622
+ if not helloSent then
623
+ pollBody.hello = { loadstringReady = LOADSTRING_READY }
624
+ end
625
+ local ok, response = postJSON("/test-session/poll", pollBody)
626
+ if ok then
627
+ helloSent = true
628
+ end
127
629
  if ok and response and response.Body then
128
630
  local decodeOk, body = pcall(function()
129
631
  return HttpService:JSONDecode(response.Body)
@@ -143,6 +645,7 @@ task.spawn(function()
143
645
  outputBuffer = {}
144
646
  postJSON("/test-session/log", {
145
647
  sessionId = SESSION_ID,
648
+ source = "server",
146
649
  messages = final,
147
650
  })
148
651
  end
@@ -160,6 +663,8 @@ task.spawn(function()
160
663
  end)
161
664
  end
162
665
  return
666
+ elseif body.command and body.command.cmd == "eval" then
667
+ handleEvalCommand(body.command.args)
163
668
  end
164
669
  end
165
670
  end
@@ -168,6 +673,362 @@ task.spawn(function()
168
673
  end)
169
674
  ]]
170
675
 
676
+ -- ----------------------------------------------------------------------------
677
+ -- CLIENT companion template. Runs as a LocalScript in StarterPlayerScripts
678
+ -- (which auto-clones into each Player's PlayerScripts on join). Each Player
679
+ -- gets their own copy and identifies to the bridge by userId. Only handles
680
+ -- 'eval' commands and streams its local LogService.
681
+ --
682
+ -- It does NOT call EndTest (only the server can do that), so the only stop
683
+ -- signal it cares about is the bridge's `ended` flag.
684
+ -- ----------------------------------------------------------------------------
685
+ local CLIENT_COMPANION_SCRIPT_TEMPLATE = [[-- _MCPTestCompanion_Client (auto-generated, safe to delete)
686
+ -- Runs in each Player's PlayerScripts during a Studio test session started by
687
+ -- the MCP plugin. Streams local LogService output to the server companion via
688
+ -- RemoteEvents and runs run_live_lua eval commands dispatched through a
689
+ -- RemoteFunction.
690
+ --
691
+ -- IMPORTANT: HttpService is server-only in Roblox. LocalScripts CANNOT make
692
+ -- HTTP requests (they'll silently fail with HttpError). So everything goes
693
+ -- through Remotes. The server companion in ServerScriptService is the only
694
+ -- HTTP gateway and forwards messages on our behalf.
695
+ --
696
+ -- DO NOT modify or delete while a play test is running — it will be cleaned
697
+ -- up automatically by the MCP plugin.
698
+
699
+ local LogService = game:GetService("LogService")
700
+ local Players = game:GetService("Players")
701
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
702
+
703
+ local LOG_FLUSH_INTERVAL = 0.25
704
+ local MAX_BUFFER = 5000
705
+ local REMOTE_WAIT_TIMEOUT = 30 -- seconds
706
+
707
+ local localPlayer = Players.LocalPlayer
708
+ if not localPlayer then return end -- LocalScript guarantees LocalPlayer; bail otherwise
709
+ local PLAYER_NAME = localPlayer.Name
710
+
711
+ -- Wait for the server companion to create the Remotes in ReplicatedStorage.
712
+ -- The server companion runs slightly before us in test mode (Server scripts
713
+ -- start before LocalScripts), but we still WaitForChild defensively.
714
+ local clientRelay = ReplicatedStorage:WaitForChild("_MCPClientRelay", REMOTE_WAIT_TIMEOUT)
715
+ local clientHello = ReplicatedStorage:WaitForChild("_MCPClientHello", REMOTE_WAIT_TIMEOUT)
716
+ local clientLogs = ReplicatedStorage:WaitForChild("_MCPClientLogs", REMOTE_WAIT_TIMEOUT)
717
+ if not (clientRelay and clientHello and clientLogs) then
718
+ -- Server companion didn't set up Remotes in time. Nothing we can do.
719
+ warn("[_MCPTestCompanion_Client] Remotes not found in ReplicatedStorage — server companion missing or slow")
720
+ return
721
+ end
722
+
723
+ -- Probe loadstring availability ONCE up front. Note: client-side gating may
724
+ -- differ from server's ServerScriptService.LoadStringEnabled, so we probe.
725
+ local LOADSTRING_READY = false
726
+ do
727
+ local ok, fn = pcall(loadstring, "return 1")
728
+ if ok and type(fn) == "function" then
729
+ local execOk, execVal = pcall(fn)
730
+ if execOk and execVal == 1 then
731
+ LOADSTRING_READY = true
732
+ end
733
+ end
734
+ end
735
+
736
+ -- Announce ourselves to the server companion, which will register us with
737
+ -- the bridge over HTTP on our behalf.
738
+ clientHello:FireServer({ loadstringReady = LOADSTRING_READY })
739
+
740
+ -- ---------------------------------------------------------------------------
741
+ -- Result serializer (matches the server companion's serializer 1:1).
742
+ -- ---------------------------------------------------------------------------
743
+ local function serializeValue(value, depth, seen)
744
+ depth = depth or 0
745
+ seen = seen or {}
746
+ if depth > 8 then return "<max depth>" end
747
+ local t = typeof(value)
748
+ if value == nil then
749
+ return nil
750
+ elseif t == "string" then
751
+ if #value > 65536 then return string.sub(value, 1, 65536) .. "...<truncated>" end
752
+ return value
753
+ elseif t == "number" then
754
+ if value ~= value then return "NaN" end
755
+ if value == math.huge then return "Infinity" end
756
+ if value == -math.huge then return "-Infinity" end
757
+ return value
758
+ elseif t == "boolean" then
759
+ return value
760
+ elseif t == "Vector3" then
761
+ return { type = "Vector3", X = value.X, Y = value.Y, Z = value.Z }
762
+ elseif t == "Vector2" then
763
+ return { type = "Vector2", X = value.X, Y = value.Y }
764
+ elseif t == "CFrame" then
765
+ return { type = "CFrame", components = { value:GetComponents() } }
766
+ elseif t == "Color3" then
767
+ return { type = "Color3", R = value.R, G = value.G, B = value.B }
768
+ elseif t == "BrickColor" then
769
+ return { type = "BrickColor", Name = value.Name }
770
+ elseif t == "UDim" then
771
+ return { type = "UDim", Scale = value.Scale, Offset = value.Offset }
772
+ elseif t == "UDim2" then
773
+ return {
774
+ type = "UDim2",
775
+ X = { Scale = value.X.Scale, Offset = value.X.Offset },
776
+ Y = { Scale = value.Y.Scale, Offset = value.Y.Offset },
777
+ }
778
+ elseif t == "Rect" then
779
+ return { type = "Rect", Min = { X = value.Min.X, Y = value.Min.Y }, Max = { X = value.Max.X, Y = value.Max.Y } }
780
+ elseif t == "Ray" then
781
+ return {
782
+ type = "Ray",
783
+ Origin = { X = value.Origin.X, Y = value.Origin.Y, Z = value.Origin.Z },
784
+ Direction = { X = value.Direction.X, Y = value.Direction.Y, Z = value.Direction.Z },
785
+ }
786
+ elseif t == "NumberRange" then
787
+ return { type = "NumberRange", Min = value.Min, Max = value.Max }
788
+ elseif t == "EnumItem" then
789
+ return { type = "EnumItem", Name = tostring(value), Value = value.Value }
790
+ elseif t == "Instance" then
791
+ local fullName = "<unparented>"
792
+ pcall(function() fullName = value:GetFullName() end)
793
+ return { type = "Instance", ClassName = value.ClassName, Name = value.Name, Path = fullName }
794
+ elseif t == "table" then
795
+ if seen[value] then return "<cycle>" end
796
+ seen[value] = true
797
+ local result = {}
798
+ local count = 0
799
+ for k, v in pairs(value) do
800
+ count += 1
801
+ if count > 200 then result["__truncated__"] = true; break end
802
+ local keyStr = type(k) == "string" and k or tostring(k)
803
+ result[keyStr] = serializeValue(v, depth + 1, seen)
804
+ end
805
+ seen[value] = nil
806
+ return result
807
+ elseif t == "function" then
808
+ return "<function>"
809
+ elseif t == "thread" then
810
+ return "<thread>"
811
+ elseif t == "buffer" then
812
+ return "<buffer>"
813
+ else
814
+ local ok, str = pcall(tostring, value)
815
+ return ok and str or ("<" .. t .. ">")
816
+ end
817
+ end
818
+
819
+ -- ---------------------------------------------------------------------------
820
+ -- LogService streaming → batched FireServer
821
+ -- ---------------------------------------------------------------------------
822
+ local outputBuffer = {}
823
+ LogService.MessageOut:Connect(function(message, messageType)
824
+ if #outputBuffer >= MAX_BUFFER then
825
+ table.remove(outputBuffer, 1)
826
+ end
827
+ table.insert(outputBuffer, {
828
+ message = tostring(message),
829
+ messageType = messageType.Name,
830
+ timestamp = os.time(),
831
+ })
832
+ end)
833
+
834
+ task.spawn(function()
835
+ while true do
836
+ if #outputBuffer > 0 then
837
+ local toSend = outputBuffer
838
+ outputBuffer = {}
839
+ -- FireServer is fire-and-forget; the server companion will tag
840
+ -- with our PLAYER_NAME and forward to /test-session/log.
841
+ pcall(function() clientLogs:FireServer(toSend) end)
842
+ end
843
+ task.wait(LOG_FLUSH_INTERVAL)
844
+ end
845
+ end)
846
+
847
+ -- ---------------------------------------------------------------------------
848
+ -- Eval handler. The server companion calls clientRelay:InvokeClient(player,
849
+ -- args), which suspends the server until we return a payload table. We do
850
+ -- the same setfenv/xpcall sandbox dance as the server, with our own watchdog.
851
+ -- ---------------------------------------------------------------------------
852
+ local function buildEvalEnv()
853
+ return setmetatable({
854
+ game = game,
855
+ workspace = workspace,
856
+ Players = Players,
857
+ Workspace = workspace,
858
+ ReplicatedStorage = ReplicatedStorage,
859
+ StarterGui = game:GetService("StarterGui"),
860
+ StarterPlayer = game:GetService("StarterPlayer"),
861
+ Lighting = game:GetService("Lighting"),
862
+ SoundService = game:GetService("SoundService"),
863
+ TweenService = game:GetService("TweenService"),
864
+ RunService = game:GetService("RunService"),
865
+ UserInputService = game:GetService("UserInputService"),
866
+ ContextActionService = game:GetService("ContextActionService"),
867
+ CollectionService = game:GetService("CollectionService"),
868
+ GuiService = game:GetService("GuiService"),
869
+ TextService = game:GetService("TextService"),
870
+ MarketplaceService = game:GetService("MarketplaceService"),
871
+ Instance = Instance,
872
+ Vector3 = Vector3, Vector2 = Vector2, CFrame = CFrame, Color3 = Color3,
873
+ BrickColor = BrickColor, UDim = UDim, UDim2 = UDim2, Rect = Rect, Ray = Ray,
874
+ Region3 = Region3, NumberSequence = NumberSequence,
875
+ NumberSequenceKeypoint = NumberSequenceKeypoint, ColorSequence = ColorSequence,
876
+ ColorSequenceKeypoint = ColorSequenceKeypoint, NumberRange = NumberRange,
877
+ TweenInfo = TweenInfo, Font = Font, Enum = Enum,
878
+ print = print, warn = warn, error = error, assert = assert,
879
+ type = type, typeof = typeof, tostring = tostring, tonumber = tonumber,
880
+ pairs = pairs, ipairs = ipairs, next = next, select = select, unpack = unpack,
881
+ pcall = pcall, xpcall = xpcall,
882
+ rawget = rawget, rawset = rawset, rawequal = rawequal,
883
+ setmetatable = setmetatable, getmetatable = getmetatable, newproxy = newproxy,
884
+ table = table, string = string, math = math,
885
+ os = { time = os.time, date = os.date, difftime = os.difftime, clock = os.clock },
886
+ coroutine = coroutine, bit32 = bit32, utf8 = utf8, buffer = buffer,
887
+ task = task, wait = task.wait, delay = task.delay, spawn = task.spawn, defer = task.defer,
888
+ loadstring = loadstring,
889
+ require = require,
890
+ debug = debug,
891
+ tick = tick,
892
+ LocalPlayer = localPlayer,
893
+ PlayerGui = localPlayer:FindFirstChildOfClass("PlayerGui"),
894
+ }, { __index = _G })
895
+ end
896
+
897
+ local function runEval(args)
898
+ -- args = { replyId, code, timeoutMs, captureLogs }
899
+ if type(args) ~= "table" or type(args.code) ~= "string" then
900
+ return { ok = false, errorType = "client_protocol_error", error = "Invalid eval args" }
901
+ end
902
+ local code = args.code
903
+ local timeoutMs = type(args.timeoutMs) == "number" and args.timeoutMs or 5000
904
+ local captureLogs = args.captureLogs ~= false
905
+
906
+ local startedAt = os.clock()
907
+ local replied = false
908
+ local resultPayload = nil
909
+ local function reply(payload)
910
+ if replied then return end
911
+ replied = true
912
+ payload.durationMs = math.floor((os.clock() - startedAt) * 1000)
913
+ resultPayload = payload
914
+ end
915
+
916
+ if not LOADSTRING_READY then
917
+ reply({
918
+ ok = false,
919
+ errorType = "loadstring_disabled",
920
+ error = "loadstring is disabled on this client. (LocalScript loadstring depends on Studio settings; ServerScriptService.LoadStringEnabled may also be required.)",
921
+ })
922
+ return resultPayload
923
+ end
924
+
925
+ local localLogs = {}
926
+ local logConn = nil
927
+ if captureLogs then
928
+ logConn = LogService.MessageOut:Connect(function(message, messageType)
929
+ if #localLogs >= 200 then return end
930
+ table.insert(localLogs, {
931
+ message = tostring(message),
932
+ messageType = messageType.Name,
933
+ timestamp = os.time(),
934
+ })
935
+ end)
936
+ end
937
+ local function detachLogs()
938
+ if logConn then
939
+ pcall(function() logConn:Disconnect() end)
940
+ logConn = nil
941
+ end
942
+ end
943
+
944
+ -- Capture both return values directly (loadstring returns nil + error
945
+ -- message on syntax failure rather than throwing).
946
+ local fnOrErr, compileErr = loadstring(code, "run_live_lua_client")
947
+ if type(fnOrErr) ~= "function" then
948
+ detachLogs()
949
+ reply({
950
+ ok = false,
951
+ errorType = "compile_error",
952
+ error = "Failed to compile: " .. tostring(compileErr or fnOrErr or "<unknown>"),
953
+ logs = captureLogs and localLogs or nil,
954
+ })
955
+ return resultPayload
956
+ end
957
+ setfenv(fnOrErr, buildEvalEnv())
958
+
959
+ -- Local watchdog: if user code yields longer than timeoutMs, the watchdog
960
+ -- pre-fills resultPayload with a timeout response, then the slow code
961
+ -- still finishes (we can't kill it cleanly) but its result is discarded.
962
+ task.delay(timeoutMs / 1000, function()
963
+ if replied then return end
964
+ detachLogs()
965
+ reply({
966
+ ok = false,
967
+ errorType = "timeout",
968
+ error = string.format("Eval did not return within %d ms (the spawned thread may still be running in the client).", timeoutMs),
969
+ logs = captureLogs and localLogs or nil,
970
+ })
971
+ end)
972
+
973
+ local results = table.pack(xpcall(fnOrErr, function(err)
974
+ return { error = tostring(err), traceback = debug.traceback(nil, 2) }
975
+ end))
976
+ local execOk = results[1]
977
+ if replied then
978
+ detachLogs()
979
+ return resultPayload
980
+ end
981
+
982
+ -- LogService.MessageOut events are deferred and need TWO Heartbeats
983
+ -- to actually invoke listeners (verified empirically — one task.wait
984
+ -- isn't enough). Yield twice so prints fired during the eval land
985
+ -- in localLogs before we detach + reply. Total cost ~32ms.
986
+ if captureLogs then
987
+ task.wait()
988
+ task.wait()
989
+ end
990
+
991
+ if not execOk then
992
+ detachLogs()
993
+ local err = results[2] or {}
994
+ reply({
995
+ ok = false,
996
+ errorType = "runtime_error",
997
+ error = (type(err) == "table" and err.error) or tostring(err),
998
+ traceback = (type(err) == "table") and err.traceback or nil,
999
+ logs = captureLogs and localLogs or nil,
1000
+ })
1001
+ return resultPayload
1002
+ end
1003
+
1004
+ local values = {}
1005
+ for i = 2, results.n do
1006
+ values[i - 1] = serializeValue(results[i], 0, {})
1007
+ end
1008
+ detachLogs()
1009
+ reply({
1010
+ ok = true,
1011
+ values = values,
1012
+ logs = captureLogs and localLogs or nil,
1013
+ })
1014
+ return resultPayload
1015
+ end
1016
+
1017
+ clientRelay.OnClientInvoke = function(args)
1018
+ -- pcall the whole thing so a runtime crash inside runEval still produces
1019
+ -- a deterministic reply for the server-side InvokeClient caller.
1020
+ local ok, payloadOrErr = pcall(runEval, args)
1021
+ if ok and type(payloadOrErr) == "table" then
1022
+ return payloadOrErr
1023
+ end
1024
+ return {
1025
+ ok = false,
1026
+ errorType = "client_protocol_error",
1027
+ error = "runEval crashed: " .. tostring(payloadOrErr),
1028
+ }
1029
+ end
1030
+ ]]
1031
+
171
1032
  -- gsub-safe replacement (treat replacement as plain string, not a pattern).
172
1033
  local function plainReplace(haystack, needle, replacement)
173
1034
  -- Use string.find with plain=true to avoid pattern interpretation in needle.
@@ -194,31 +1055,44 @@ local function cleanupTestCompanions()
194
1055
  count += 1
195
1056
  end
196
1057
  end)
197
- -- Belt & suspenders: also remove any name-matching script in
198
- -- ServerScriptService in case the tag was somehow stripped.
1058
+ -- Belt & suspenders: also remove any name-matching scripts in the
1059
+ -- two known parents in case the tag was somehow stripped.
199
1060
  pcall(function()
200
1061
  local sss = game:GetService("ServerScriptService")
201
1062
  for _, child in ipairs(sss:GetChildren()) do
202
- if child.Name == TEST_COMPANION_NAME then
1063
+ if child.Name == TEST_COMPANION_NAME or child.Name == TEST_COMPANION_NAME_CLIENT then
203
1064
  pcall(function() child:Destroy() end)
204
1065
  count += 1
205
1066
  end
206
1067
  end
207
1068
  end)
1069
+ pcall(function()
1070
+ local starterPlayer = game:GetService("StarterPlayer")
1071
+ local sps = starterPlayer and starterPlayer:FindFirstChild("StarterPlayerScripts")
1072
+ if sps then
1073
+ for _, child in ipairs(sps:GetChildren()) do
1074
+ if child.Name == TEST_COMPANION_NAME or child.Name == TEST_COMPANION_NAME_CLIENT then
1075
+ pcall(function() child:Destroy() end)
1076
+ count += 1
1077
+ end
1078
+ end
1079
+ end
1080
+ end)
208
1081
  return count
209
1082
  end
210
1083
 
211
1084
  local function injectTestCompanion(serverUrl, sessionId)
212
- -- Always sweep before creating to guarantee at most one companion exists.
1085
+ -- Always sweep before creating to guarantee at most one of each companion exists.
213
1086
  cleanupTestCompanions()
214
1087
 
215
- local source = COMPANION_SCRIPT_TEMPLATE
216
- source = plainReplace(source, "__MCP_SERVER_URL__", serverUrl)
217
- source = plainReplace(source, "__MCP_SESSION_ID__", sessionId)
1088
+ -- ----- SERVER companion -----
1089
+ local serverSource = COMPANION_SCRIPT_TEMPLATE
1090
+ serverSource = plainReplace(serverSource, "__MCP_SERVER_URL__", serverUrl)
1091
+ serverSource = plainReplace(serverSource, "__MCP_SESSION_ID__", sessionId)
218
1092
 
219
- local script = Instance.new("Script")
220
- script.Name = TEST_COMPANION_NAME
221
- script.Source = source
1093
+ local serverScript = Instance.new("Script")
1094
+ serverScript.Name = TEST_COMPANION_NAME
1095
+ serverScript.Source = serverSource
222
1096
  -- IMPORTANT: Archivable MUST stay true (the default).
223
1097
  -- ExecutePlayModeAsync builds the test DataModel by Clone()-ing the Edit
224
1098
  -- DataModel, and Instance:Clone() silently returns nil for any instance
@@ -231,14 +1105,42 @@ local function injectTestCompanion(serverUrl, sessionId)
231
1105
  -- We rely on the cleanup sweeps (on plugin activate, before each test,
232
1106
  -- after each test) to keep companions out of the saved file. The script
233
1107
  -- is also tagged with TEST_COMPANION_TAG so any orphan is easy to find.
234
- script.Archivable = true
235
- script.Parent = game:GetService("ServerScriptService")
1108
+ serverScript.Archivable = true
1109
+ serverScript.Parent = game:GetService("ServerScriptService")
1110
+ pcall(function()
1111
+ CollectionService:AddTag(serverScript, TEST_COMPANION_TAG)
1112
+ end)
236
1113
 
1114
+ -- ----- CLIENT companion -----
1115
+ -- LocalScripts in StarterPlayer.StarterPlayerScripts auto-clone into
1116
+ -- each Player's PlayerScripts on join, so this single LocalScript
1117
+ -- yields one companion per player in the test.
1118
+ local clientScript = nil
237
1119
  pcall(function()
238
- CollectionService:AddTag(script, TEST_COMPANION_TAG)
1120
+ local clientSource = CLIENT_COMPANION_SCRIPT_TEMPLATE
1121
+ clientSource = plainReplace(clientSource, "__MCP_SERVER_URL__", serverUrl)
1122
+ clientSource = plainReplace(clientSource, "__MCP_SESSION_ID__", sessionId)
1123
+
1124
+ local sp = game:GetService("StarterPlayer")
1125
+ local sps = sp:FindFirstChild("StarterPlayerScripts")
1126
+ if not sps then
1127
+ -- Some empty places ship without StarterPlayerScripts. Create it.
1128
+ sps = Instance.new("StarterPlayerScripts")
1129
+ sps.Parent = sp
1130
+ end
1131
+
1132
+ local ls = Instance.new("LocalScript")
1133
+ ls.Name = TEST_COMPANION_NAME_CLIENT
1134
+ ls.Source = clientSource
1135
+ ls.Archivable = true
1136
+ ls.Parent = sps
1137
+ pcall(function()
1138
+ CollectionService:AddTag(ls, TEST_COMPANION_TAG)
1139
+ end)
1140
+ clientScript = ls
239
1141
  end)
240
1142
 
241
- return script
1143
+ return serverScript, clientScript
242
1144
  end
243
1145
 
244
1146
  -- Track output log for get_output
@@ -282,18 +1184,41 @@ local function serializeValue(value)
282
1184
  end
283
1185
  end
284
1186
 
285
- -- Helper to log an action for undo tracking
286
- local function logAction(actionType, target, summary, details)
287
- -- Clear redo history when a new action is performed
288
- redoHistory = {}
1187
+ -- ============================================
1188
+ -- DEFERRED ACTION LOGGING
1189
+ -- ============================================
1190
+ -- Handlers call logAction() to record what they just did for the
1191
+ -- get_history / undo / redo tools. We don't want a tracked entry sitting
1192
+ -- in actionHistory unless Studio's ChangeHistoryService actually committed
1193
+ -- a waypoint for the same operation — otherwise undo() will pop our
1194
+ -- tracked entry while Studio reverts something completely different.
1195
+ --
1196
+ -- Architecture: logAction() stashes the entry in `_pendingActionLog`.
1197
+ -- processRequest() snapshots GetCanUndo / GetCanRedo around the
1198
+ -- TryBeginRecording / FinishRecording window, then calls
1199
+ -- commitPendingLog() iff a real waypoint was added. Otherwise it calls
1200
+ -- discardPendingLog().
1201
+ local _pendingActionLog = nil -- single-slot buffer for current request
289
1202
 
290
- table.insert(actionHistory, {
1203
+ local function logAction(actionType, target, summary, details)
1204
+ -- Stash; processRequest decides whether to keep it.
1205
+ _pendingActionLog = {
291
1206
  action = actionType,
292
1207
  target = target,
293
1208
  summary = summary,
294
1209
  details = details or {},
295
- timestamp = os.time()
296
- })
1210
+ timestamp = os.time(),
1211
+ }
1212
+ end
1213
+
1214
+ local function commitPendingLog()
1215
+ if not _pendingActionLog then return end
1216
+ local entry = _pendingActionLog
1217
+ _pendingActionLog = nil
1218
+
1219
+ -- A new committed action clears the redo branch.
1220
+ redoHistory = {}
1221
+ table.insert(actionHistory, entry)
297
1222
 
298
1223
  -- Keep history from growing too large
299
1224
  if #actionHistory > MAX_ACTION_HISTORY then
@@ -301,6 +1226,10 @@ local function logAction(actionType, target, summary, details)
301
1226
  end
302
1227
  end
303
1228
 
1229
+ local function discardPendingLog()
1230
+ _pendingActionLog = nil
1231
+ end
1232
+
304
1233
  -- Connect to LogService to capture output
305
1234
  LogService.MessageOut:Connect(function(message, messageType)
306
1235
  table.insert(outputBuffer, {
@@ -1014,9 +1943,6 @@ local mutationEndpoints = {
1014
1943
  ["/api/set-calculated-property"] = "Set Calculated Property",
1015
1944
  ["/api/set-relative-property"] = "Set Relative Property",
1016
1945
  ["/api/set-script-source"] = "Set Script Source",
1017
- ["/api/edit-script-lines"] = "Edit Script Lines",
1018
- ["/api/insert-script-lines"] = "Insert Script Lines",
1019
- ["/api/delete-script-lines"] = "Delete Script Lines",
1020
1946
  ["/api/set-attribute"] = "Set Attribute",
1021
1947
  ["/api/delete-attribute"] = "Delete Attribute",
1022
1948
  ["/api/add-tag"] = "Add Tag",
@@ -1024,6 +1950,14 @@ local mutationEndpoints = {
1024
1950
  ["/api/clone-instance"] = "Clone Instance",
1025
1951
  ["/api/move-instance"] = "Move Instance",
1026
1952
  ["/api/insert-asset"] = "Insert Asset",
1953
+ -- Script editing: modern recording API (migrated from legacy SetWaypoint)
1954
+ ["/api/edit-script"] = "Edit Script",
1955
+ ["/api/find-and-replace-in-scripts"] = "Find and Replace in Scripts",
1956
+ -- Arbitrary Lua: wraps the WHOLE execution in one waypoint. Auto-rolls
1957
+ -- back on error via FinishRecording(Cancel). Mutations from yielded /
1958
+ -- task.spawn'd code that runs AFTER the handler returns are NOT captured
1959
+ -- (documented limitation).
1960
+ ["/api/execute-lua"] = "Execute Lua",
1027
1961
  }
1028
1962
 
1029
1963
  -- Endpoint to handler mapping (populated after handlers are defined below)
@@ -1048,18 +1982,118 @@ processRequest = function(request)
1048
1982
  -- Check if this is a mutation endpoint
1049
1983
  local recordingName = mutationEndpoints[endpoint]
1050
1984
  if recordingName then
1051
- -- Wrap in ChangeHistoryService recording for proper undo
1985
+ -- ─────────────────────────────────────────────────────────────────
1986
+ -- Two parallel undo channels — CHS waypoints AND plugin-side log
1987
+ -- ─────────────────────────────────────────────────────────────────
1988
+ -- Most mutations flow through ChangeHistoryService (CHS) waypoints:
1989
+ -- create_object, set_property, delete_object, etc. — these we track
1990
+ -- BOTH in CHS (so Ctrl+Z and our undo tool work) AND in actionHistory
1991
+ -- (so get_history can show readable summaries).
1992
+ --
1993
+ -- BUT script `Source` property changes are architecturally decoupled
1994
+ -- from CHS in Studio: the script editor maintains its OWN undo stack
1995
+ -- separate from CHS. A direct `instance.Source = "..."` assignment
1996
+ -- changes the value, but CHS will NOT capture it in a waypoint — so
1997
+ -- CHS:Undo() won't revert it (it'll skip ahead and revert the previous
1998
+ -- waypoint, destroying something unrelated). This is the root cause
1999
+ -- of "Bug 1: edit_script undo doesn't actually revert source".
2000
+ --
2001
+ -- Fix: for source-only handlers (edit_script / set_script_source /
2002
+ -- find_and_replace_in_scripts) we Cancel the CHS recording (no empty
2003
+ -- waypoint) and instead store a pre/post-Source snapshot inside the
2004
+ -- tracked action's `details.scriptEdits`. Our undo handler detects
2005
+ -- these entries and restores the source manually via direct
2006
+ -- assignment, bypassing CHS entirely. Real undo, no side effects.
2007
+ --
2008
+ -- Result sentinels that drive the decision:
2009
+ -- • result._isSourceEditOnly == true →
2010
+ -- Script source changes only, no CHS waypoint to keep.
2011
+ -- Cancel recording, KEEP tracked log (with snapshots).
2012
+ -- • result._didNotMutate == true →
2013
+ -- Handler knows it did nothing (e.g. find_and_replace 0 matches).
2014
+ -- Cancel recording, DROP tracked log.
2015
+ -- • result._mutatesUnknown == true →
2016
+ -- Arbitrary user code (execute_lua). Use a synchronous
2017
+ -- descendant-count delta to decide.
2018
+ -- • Otherwise →
2019
+ -- Trusted mutator. Commit recording, KEEP tracked log.
2020
+ --
2021
+ -- Why synchronous descendant-count delta (and NOT DescendantAdded)?
2022
+ -- The DescendantAdded / DescendantRemoving signals fire DEFERRED in
2023
+ -- Studio plugins — by the time the handler returns and we'd read a
2024
+ -- counter, no events have fired yet. Earlier v2 used those signals
2025
+ -- and silently rolled back every execute_lua mutation. Synchronous
2026
+ -- snapshot via #game:GetDescendants() works around the deferral —
2027
+ -- it's O(N) but only runs for the unknown-mutator case.
2028
+ _pendingActionLog = nil -- start clean
2029
+
2030
+ -- Snapshot tree size BEFORE handler, only when we'll need it.
2031
+ -- For execute_lua (the only _mutatesUnknown source) we need the
2032
+ -- before/after delta; everything else trusts the handler's sentinel.
2033
+ local descCountBefore
2034
+ if endpoint == "/api/execute-lua" then
2035
+ descCountBefore = #game:GetDescendants()
2036
+ end
2037
+
1052
2038
  local recording = ChangeHistoryService:TryBeginRecording("MCP: " .. recordingName)
1053
2039
  local success, result = pcall(handler, data)
1054
2040
 
2041
+ local handlerOK = success and not (result and result.error)
2042
+
2043
+ -- commitCHS: should we keep the CHS waypoint?
2044
+ -- commitLog: should we keep the tracked action entry?
2045
+ local commitCHS = false
2046
+ local commitLog = false
2047
+
2048
+ if handlerOK then
2049
+ local r = type(result) == "table" and result or nil
2050
+ if r and r._isSourceEditOnly == true then
2051
+ -- Source-only: Cancel CHS (no real waypoint), keep tracked log
2052
+ -- with snapshots for plugin-side undo.
2053
+ commitCHS = false
2054
+ commitLog = true
2055
+ elseif r and r._didNotMutate == true then
2056
+ -- Genuine no-op: drop both.
2057
+ commitCHS = false
2058
+ commitLog = false
2059
+ elseif r and r._mutatesUnknown == true then
2060
+ -- execute_lua: synchronous tree-size delta tells us if
2061
+ -- user code added/removed instances. Property-only mutations
2062
+ -- (e.g. just changing Position) won't be detected — that's an
2063
+ -- accepted edge case; users wanting full tracking should use
2064
+ -- the dedicated set_property tool.
2065
+ local treeChanged = (#game:GetDescendants() ~= descCountBefore)
2066
+ commitCHS = treeChanged
2067
+ commitLog = treeChanged
2068
+ else
2069
+ -- Trusted mutator (create_object, set_property, etc.) →
2070
+ -- always commit on handler success.
2071
+ commitCHS = true
2072
+ commitLog = true
2073
+ end
2074
+ end
2075
+
1055
2076
  if recording then
1056
- if success and not (result and result.error) then
2077
+ if commitCHS then
1057
2078
  ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
1058
2079
  else
1059
2080
  ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
1060
2081
  end
1061
2082
  end
1062
2083
 
2084
+ if commitLog then
2085
+ commitPendingLog()
2086
+ else
2087
+ discardPendingLog()
2088
+ end
2089
+
2090
+ -- Strip internal sentinels from the response so callers don't see them.
2091
+ if type(result) == "table" then
2092
+ result._mutatesUnknown = nil
2093
+ result._didNotMutate = nil
2094
+ result._isSourceEditOnly = nil
2095
+ end
2096
+
1063
2097
  if success then
1064
2098
  return result
1065
2099
  else
@@ -2719,55 +3753,62 @@ handlers.setScriptSource = function(requestData)
2719
3753
  sourceToSet = sourceToSet:gsub("\\t", "\t")
2720
3754
  sourceToSet = sourceToSet:gsub("\\r", "\r")
2721
3755
  sourceToSet = sourceToSet:gsub("\\\\", "\\")
2722
- local updateSuccess, updateResult = pcall(function()
2723
- local oldSourceLength = string.len(instance.Source)
2724
3756
 
2725
- ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
2726
- return sourceToSet
2727
- end)
2728
-
2729
- ChangeHistoryService:SetWaypoint("Set script source: " .. instance.Name)
2730
-
2731
- return {
2732
- success = true,
2733
- instancePath = instancePath,
2734
- oldSourceLength = oldSourceLength,
2735
- newSourceLength = string.len(sourceToSet),
2736
- method = "UpdateSourceAsync",
2737
- message = "Script source updated successfully (editor-safe)"
2738
- }
2739
- end)
2740
-
2741
- if updateSuccess then
2742
- return updateResult
2743
- end
3757
+ -- Primary path: direct property assignment. Roblox's script editor
3758
+ -- maintains its own undo stack separate from ChangeHistoryService — so
3759
+ -- CHS waypoints will NOT capture this Source change. We work around
3760
+ -- this by snapshotting pre/post source into the tracked action entry;
3761
+ -- the undo handler restores the snapshot directly. The processRequest
3762
+ -- wrapper sees `_isSourceEditOnly` and Cancels the (empty) CHS
3763
+ -- recording so it doesn't pollute Studio's undo stack.
3764
+ --
3765
+ -- Caveat: if the user has unsaved buffer edits in Studio's script
3766
+ -- editor for this script, those edits will be replaced. Intentional —
3767
+ -- consistent with previous behavior, and predictable for callers.
3768
+ local preSource = instance.Source
2744
3769
 
2745
- -- Fallback to direct assignment if UpdateSourceAsync fails
2746
3770
  local directSuccess, directResult = pcall(function()
2747
- local oldSource = instance.Source
3771
+ local oldSourceLength = string.len(preSource)
2748
3772
  instance.Source = sourceToSet
2749
3773
 
2750
- ChangeHistoryService:SetWaypoint("Set script source: " .. instance.Name)
2751
-
2752
3774
  return {
2753
3775
  success = true,
2754
3776
  instancePath = instancePath,
2755
- oldSourceLength = string.len(oldSource),
3777
+ oldSourceLength = oldSourceLength,
2756
3778
  newSourceLength = string.len(sourceToSet),
2757
3779
  method = "direct",
2758
- message = "Script source updated successfully (direct assignment)"
3780
+ message = "Script source updated successfully"
2759
3781
  }
2760
3782
  end)
2761
3783
 
2762
3784
  if directSuccess then
2763
- -- Log for undo tracking
2764
- logAction("set_script_source", instancePath, "Script source replaced (" .. directResult.newSourceLength .. " chars)", {
2765
- method = "direct"
2766
- })
3785
+ -- Snapshot for plugin-side undo restoration. Stored as a list so the
3786
+ -- same code path also handles find_and_replace's batch edits.
3787
+ logAction(
3788
+ "set_script_source",
3789
+ instancePath,
3790
+ "Script source replaced (" .. directResult.newSourceLength .. " chars)",
3791
+ {
3792
+ method = "direct",
3793
+ scriptEdits = {
3794
+ {
3795
+ instancePath = instancePath,
3796
+ preSource = preSource,
3797
+ postSource = sourceToSet,
3798
+ },
3799
+ },
3800
+ }
3801
+ )
3802
+ -- Source-only change: tell processRequest to Cancel the CHS
3803
+ -- recording but keep our tracked log entry (which has the snapshots).
3804
+ directResult._isSourceEditOnly = true
2767
3805
  return directResult
2768
3806
  end
2769
3807
 
2770
- -- Final fallback: replace the script entirely
3808
+ -- Fallback: replace the script entirely (used when direct assignment
3809
+ -- throws — e.g. for some plugin-managed script kinds). This DOES
3810
+ -- mutate the tree (Destroy + Instance.new), so CHS captures it.
3811
+ -- That makes the action a regular CHS-backed mutation, not source-only.
2771
3812
  local replaceSuccess, replaceResult = pcall(function()
2772
3813
  local parent = instance.Parent
2773
3814
  local name = instance.Name
@@ -2788,8 +3829,6 @@ handlers.setScriptSource = function(requestData)
2788
3829
  newScript.Parent = parent
2789
3830
  instance:Destroy()
2790
3831
 
2791
- ChangeHistoryService:SetWaypoint("Replace script: " .. name)
2792
-
2793
3832
  return {
2794
3833
  success = true,
2795
3834
  instancePath = getInstancePath(newScript),
@@ -2799,254 +3838,28 @@ handlers.setScriptSource = function(requestData)
2799
3838
  end)
2800
3839
 
2801
3840
  if replaceSuccess then
2802
- -- Log for undo tracking
2803
3841
  logAction("set_script_source", replaceResult.instancePath, "Script replaced entirely", {
2804
3842
  method = "replace"
2805
3843
  })
3844
+ -- Replace path mutates the tree (Destroy + Instance.new) — CHS
3845
+ -- captures this naturally, so we let processRequest Commit normally.
2806
3846
  return replaceResult
2807
3847
  else
2808
3848
  return {
2809
- error = "Failed to set script source. UpdateSourceAsync failed: " .. tostring(updateResult) ..
2810
- ". Direct assignment failed: " .. tostring(directResult) ..
3849
+ error = "Failed to set script source. Direct assignment failed: " .. tostring(directResult) ..
2811
3850
  ". Replace method failed: " .. tostring(replaceResult)
2812
3851
  }
2813
3852
  end
2814
3853
  end
2815
3854
 
2816
- -- Partial Script Editing: Edit specific lines
2817
- handlers.editScriptLines = function(requestData)
2818
- local instancePath = requestData.instancePath
2819
- local startLine = requestData.startLine
2820
- local endLine = requestData.endLine
2821
- local newContent = requestData.newContent
2822
-
2823
- if not instancePath or not startLine or not endLine or not newContent then
2824
- return { error = "Instance path, startLine, endLine, and newContent are required" }
2825
- end
2826
-
2827
- -- Normalize escape sequences that may have been double-escaped
2828
- newContent = newContent:gsub("\\n", "\n")
2829
- newContent = newContent:gsub("\\t", "\t")
2830
- newContent = newContent:gsub("\\r", "\r")
2831
- newContent = newContent:gsub("\\\\", "\\")
2832
-
2833
- local instance = getInstanceByPath(instancePath)
2834
- if not instance then
2835
- return { error = "Instance not found: " .. instancePath }
2836
- end
2837
-
2838
- if not instance:IsA("LuaSourceContainer") then
2839
- return { error = "Instance is not a script-like object: " .. instance.ClassName }
2840
- end
2841
-
2842
- local success, result = pcall(function()
2843
- local lines, hadTrailingNewline = splitLines(instance.Source)
2844
- local totalLines = #lines
2845
-
2846
- if startLine < 1 or startLine > totalLines then
2847
- error("startLine out of range (1-" .. totalLines .. ")")
2848
- end
2849
- if endLine < startLine or endLine > totalLines then
2850
- error("endLine out of range (" .. startLine .. "-" .. totalLines .. ")")
2851
- end
2852
-
2853
- -- Split new content into lines
2854
- local newLines = select(1, splitLines(newContent))
2855
-
2856
- -- Build new source: lines before + new content + lines after
2857
- local resultLines = {}
2858
-
2859
- -- Lines before the edit
2860
- for i = 1, startLine - 1 do
2861
- table.insert(resultLines, lines[i])
2862
- end
2863
-
2864
- -- New content lines
2865
- for _, line in ipairs(newLines) do
2866
- table.insert(resultLines, line)
2867
- end
2868
-
2869
- -- Lines after the edit
2870
- for i = endLine + 1, totalLines do
2871
- table.insert(resultLines, lines[i])
2872
- end
2873
-
2874
- local newSource = joinLines(resultLines, hadTrailingNewline)
2875
-
2876
- -- Use UpdateSourceAsync for editor compatibility
2877
- ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
2878
- return newSource
2879
- end)
2880
-
2881
- ChangeHistoryService:SetWaypoint("Edit script lines " .. startLine .. "-" .. endLine .. ": " .. instance.Name)
2882
-
2883
- return {
2884
- success = true,
2885
- instancePath = instancePath,
2886
- editedLines = { startLine = startLine, endLine = endLine },
2887
- linesRemoved = endLine - startLine + 1,
2888
- linesAdded = #newLines,
2889
- newLineCount = #resultLines,
2890
- message = "Script lines edited successfully"
2891
- }
2892
- end)
2893
-
2894
- if success then
2895
- return result
2896
- else
2897
- return { error = "Failed to edit script lines: " .. tostring(result) }
2898
- end
2899
- end
2900
-
2901
- -- Partial Script Editing: Insert lines after a specific line
2902
- handlers.insertScriptLines = function(requestData)
2903
- local instancePath = requestData.instancePath
2904
- local afterLine = requestData.afterLine or 0 -- 0 means insert at beginning
2905
- local newContent = requestData.newContent
2906
-
2907
- if not instancePath or not newContent then
2908
- return { error = "Instance path and newContent are required" }
2909
- end
2910
-
2911
- -- Normalize escape sequences that may have been double-escaped
2912
- newContent = newContent:gsub("\\n", "\n")
2913
- newContent = newContent:gsub("\\t", "\t")
2914
- newContent = newContent:gsub("\\r", "\r")
2915
- newContent = newContent:gsub("\\\\", "\\")
2916
-
2917
- local instance = getInstanceByPath(instancePath)
2918
- if not instance then
2919
- return { error = "Instance not found: " .. instancePath }
2920
- end
2921
-
2922
- if not instance:IsA("LuaSourceContainer") then
2923
- return { error = "Instance is not a script-like object: " .. instance.ClassName }
2924
- end
2925
-
2926
- local success, result = pcall(function()
2927
- local lines, hadTrailingNewline = splitLines(instance.Source)
2928
- local totalLines = #lines
2929
-
2930
- if afterLine < 0 or afterLine > totalLines then
2931
- error("afterLine out of range (0-" .. totalLines .. ")")
2932
- end
2933
-
2934
- -- Split new content into lines
2935
- local newLines = select(1, splitLines(newContent))
2936
-
2937
- -- Build new source
2938
- local resultLines = {}
2939
-
2940
- -- Lines before insertion point
2941
- for i = 1, afterLine do
2942
- table.insert(resultLines, lines[i])
2943
- end
2944
-
2945
- -- New content lines
2946
- for _, line in ipairs(newLines) do
2947
- table.insert(resultLines, line)
2948
- end
2949
-
2950
- -- Lines after insertion point
2951
- for i = afterLine + 1, totalLines do
2952
- table.insert(resultLines, lines[i])
2953
- end
2954
-
2955
- local newSource = joinLines(resultLines, hadTrailingNewline)
2956
-
2957
- -- Use UpdateSourceAsync for editor compatibility
2958
- ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
2959
- return newSource
2960
- end)
2961
-
2962
- ChangeHistoryService:SetWaypoint("Insert script lines after line " .. afterLine .. ": " .. instance.Name)
2963
-
2964
- return {
2965
- success = true,
2966
- instancePath = instancePath,
2967
- insertedAfterLine = afterLine,
2968
- linesInserted = #newLines,
2969
- newLineCount = #resultLines,
2970
- message = "Script lines inserted successfully"
2971
- }
2972
- end)
2973
-
2974
- if success then
2975
- return result
2976
- else
2977
- return { error = "Failed to insert script lines: " .. tostring(result) }
2978
- end
2979
- end
2980
-
2981
- -- Partial Script Editing: Delete specific lines
2982
- handlers.deleteScriptLines = function(requestData)
2983
- local instancePath = requestData.instancePath
2984
- local startLine = requestData.startLine
2985
- local endLine = requestData.endLine
2986
-
2987
- if not instancePath or not startLine or not endLine then
2988
- return { error = "Instance path, startLine, and endLine are required" }
2989
- end
2990
-
2991
- local instance = getInstanceByPath(instancePath)
2992
- if not instance then
2993
- return { error = "Instance not found: " .. instancePath }
2994
- end
2995
-
2996
- if not instance:IsA("LuaSourceContainer") then
2997
- return { error = "Instance is not a script-like object: " .. instance.ClassName }
2998
- end
2999
-
3000
- local success, result = pcall(function()
3001
- local lines, hadTrailingNewline = splitLines(instance.Source)
3002
- local totalLines = #lines
3003
-
3004
- if startLine < 1 or startLine > totalLines then
3005
- error("startLine out of range (1-" .. totalLines .. ")")
3006
- end
3007
- if endLine < startLine or endLine > totalLines then
3008
- error("endLine out of range (" .. startLine .. "-" .. totalLines .. ")")
3009
- end
3010
-
3011
- -- Build new source without the deleted lines
3012
- local resultLines = {}
3013
-
3014
- for i = 1, startLine - 1 do
3015
- table.insert(resultLines, lines[i])
3016
- end
3017
-
3018
- for i = endLine + 1, totalLines do
3019
- table.insert(resultLines, lines[i])
3020
- end
3021
-
3022
- local newSource = joinLines(resultLines, hadTrailingNewline)
3023
-
3024
- -- Use UpdateSourceAsync for editor compatibility
3025
- ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
3026
- return newSource
3027
- end)
3028
-
3029
- ChangeHistoryService:SetWaypoint("Delete script lines " .. startLine .. "-" .. endLine .. ": " .. instance.Name)
3030
-
3031
- return {
3032
- success = true,
3033
- instancePath = instancePath,
3034
- deletedLines = { startLine = startLine, endLine = endLine },
3035
- linesDeleted = endLine - startLine + 1,
3036
- newLineCount = #resultLines,
3037
- message = "Script lines deleted successfully"
3038
- }
3039
- end)
3040
-
3041
- if success then
3042
- return result
3043
- else
3044
- return { error = "Failed to delete script lines: " .. tostring(result) }
3045
- end
3046
- end
3047
-
3048
3855
  -- ============================================
3049
3856
  -- CLAUDE CODE-STYLE SCRIPT EDITING TOOLS
3857
+ --
3858
+ -- The legacy line-based partial editors (editScriptLines /
3859
+ -- insertScriptLines / deleteScriptLines) were removed. They've been
3860
+ -- superseded by handlers.editScript below: string-based edits don't
3861
+ -- suffer from line-number drift after each modification, and the
3862
+ -- companion validation step rejects edits that break syntax.
3050
3863
  -- ============================================
3051
3864
 
3052
3865
  -- Helper: Count occurrences of a substring
@@ -3166,12 +3979,20 @@ handlers.editScript = function(requestData)
3166
3979
  end
3167
3980
  end
3168
3981
 
3169
- -- Apply the change
3170
- ScriptEditorService:UpdateSourceAsync(instance, function(oldContent)
3171
- return newSource
3172
- end)
3173
-
3174
- ChangeHistoryService:SetWaypoint("Edit script: " .. instance.Name)
3982
+ -- Apply via direct property assignment. NOTE: this does NOT produce
3983
+ -- a ChangeHistoryService waypoint (Studio's script editor maintains
3984
+ -- its own undo stack disjoint from CHS) — so undo support for this
3985
+ -- edit comes from the pre/post-source SNAPSHOT we attach to the
3986
+ -- tracked action entry below. The processRequest wrapper sees
3987
+ -- `_isSourceEditOnly = true` in our return value and Cancels the
3988
+ -- (empty) CHS recording.
3989
+ --
3990
+ -- We do NOT use ScriptEditorService:UpdateSourceAsync — same
3991
+ -- reason (and it has the same CHS limitation), with the added
3992
+ -- downside of being yield-only and harder to reason about.
3993
+ -- (Caveat: unsaved buffer edits in Studio's script editor will be
3994
+ -- replaced — same trade-off as set_script_source.)
3995
+ instance.Source = newSource
3175
3996
 
3176
3997
  -- Prepare summary for logging
3177
3998
  local shortOld = #searchStr > 30 and (string.sub(searchStr, 1, 30) .. "...") or searchStr
@@ -3187,17 +4008,37 @@ handlers.editScript = function(requestData)
3187
4008
  message = replaceAll
3188
4009
  and ("Replaced " .. occurrences .. " occurrence(s) successfully")
3189
4010
  or "Edit applied successfully",
3190
- _logSummary = shortOld:gsub("\n", "\\n") .. " → " .. shortNew:gsub("\n", "\\n")
4011
+ _logSummary = shortOld:gsub("\n", "\\n") .. " → " .. shortNew:gsub("\n", "\\n"),
4012
+ _preSource = source,
4013
+ _postSource = newSource,
3191
4014
  }
3192
4015
  end)
3193
4016
 
3194
4017
  if success then
3195
4018
  if result.success then
3196
- -- Log for undo tracking
3197
- logAction("edit_script", instancePath, result._logSummary or "Script edited", {
3198
- replacements = result.replacements
3199
- })
3200
- result._logSummary = nil -- Remove internal field
4019
+ -- Log for undo tracking. The pre/post-source snapshots live in
4020
+ -- details.scriptEdits undo restores from preSource, redo from
4021
+ -- postSource.
4022
+ logAction(
4023
+ "edit_script",
4024
+ instancePath,
4025
+ result._logSummary or "Script edited",
4026
+ {
4027
+ replacements = result.replacements,
4028
+ scriptEdits = {
4029
+ {
4030
+ instancePath = instancePath,
4031
+ preSource = result._preSource,
4032
+ postSource = result._postSource,
4033
+ },
4034
+ },
4035
+ }
4036
+ )
4037
+ result._logSummary = nil
4038
+ result._preSource = nil
4039
+ result._postSource = nil
4040
+ -- Source-only change: Cancel CHS recording, keep tracked log.
4041
+ result._isSourceEditOnly = true
3201
4042
  end
3202
4043
  return result
3203
4044
  else
@@ -3276,6 +4117,217 @@ handlers.searchScript = function(requestData)
3276
4117
  end
3277
4118
  end
3278
4119
 
4120
+ -- grep: Claude Code-style search across the instance tree.
4121
+ --
4122
+ -- Walks descendants of `path`, pre-filters by `type` (IsA) and `glob`
4123
+ -- (Lua pattern over Name), then scans script Source for `pattern` (either
4124
+ -- line-by-line or whole-source if `multiline`). Three output modes mirror
4125
+ -- `grep -l` / `grep` / `grep -c`.
4126
+ --
4127
+ -- Request shape:
4128
+ -- { pattern, path, glob?, type? (array), caseInsensitive?, after?, before?,
4129
+ -- context?, outputMode? ("files_with_matches"|"content"|"count"),
4130
+ -- headLimit?, multiline? }
4131
+ --
4132
+ -- Response shapes:
4133
+ -- files_with_matches: { mode, pattern, scanned, matches = [{path, className, name}] }
4134
+ -- content: { mode, pattern, scanned, matches = [{path, className, name,
4135
+ -- lines = [{lineNumber, content, context = [{lineNumber, content, type}]}]}],
4136
+ -- totalMatches }
4137
+ -- count: { mode, pattern, scanned, matches = [{path, className, name, count}], totalMatches }
4138
+ handlers.grep = function(requestData)
4139
+ local pattern = requestData.pattern
4140
+ if type(pattern) ~= "string" or #pattern == 0 then
4141
+ return { error = "pattern is required (use \".*\" for name-only searches)" }
4142
+ end
4143
+
4144
+ local rootPath = requestData.path
4145
+ if type(rootPath) ~= "string" or #rootPath == 0 then
4146
+ rootPath = "game"
4147
+ end
4148
+
4149
+ local glob = requestData.glob
4150
+ if type(glob) ~= "string" or #glob == 0 then glob = nil end
4151
+
4152
+ local typeFilter = requestData.type
4153
+ if typeof(typeFilter) ~= "table" or #typeFilter == 0 then
4154
+ typeFilter = { "LuaSourceContainer" }
4155
+ end
4156
+
4157
+ local caseInsensitive = requestData.caseInsensitive == true
4158
+ local outputMode = requestData.outputMode or "files_with_matches"
4159
+ if outputMode ~= "files_with_matches" and outputMode ~= "content" and outputMode ~= "count" then
4160
+ return { error = "outputMode must be one of: files_with_matches, content, count" }
4161
+ end
4162
+
4163
+ local contextAround = tonumber(requestData.context) or 0
4164
+ local contextAfter = tonumber(requestData.after) or 0
4165
+ local contextBefore = tonumber(requestData.before) or 0
4166
+ if contextAround > 0 then
4167
+ -- -C overrides -A/-B, matching grep semantics
4168
+ contextAfter = contextAround
4169
+ contextBefore = contextAround
4170
+ end
4171
+
4172
+ local headLimit = tonumber(requestData.headLimit) or 0
4173
+ local multiline = requestData.multiline == true
4174
+
4175
+ -- Resolve the root instance.
4176
+ local rootInstance
4177
+ if rootPath == "game" or rootPath == "" then
4178
+ rootInstance = game
4179
+ else
4180
+ rootInstance = getInstanceByPath(rootPath)
4181
+ if not rootInstance then
4182
+ return { error = "Path not found: " .. rootPath }
4183
+ end
4184
+ end
4185
+
4186
+ -- Helper: case-insensitive find/match by lowercasing both inputs. We
4187
+ -- can't use a "(?i)" prefix in Lua patterns, so this is the standard
4188
+ -- workaround. We keep the original case of the matched line in the
4189
+ -- output for readability.
4190
+ local function caseFold(s)
4191
+ if caseInsensitive then return string.lower(s) else return s end
4192
+ end
4193
+
4194
+ local patternFolded = caseFold(pattern)
4195
+ local globFolded = glob and caseFold(glob) or nil
4196
+
4197
+ -- Walk descendants and apply filters.
4198
+ local descendants = rootInstance:GetDescendants()
4199
+ -- Include the root itself so e.g. grep on a specific Script path works.
4200
+ table.insert(descendants, 1, rootInstance)
4201
+
4202
+ local scanned = 0
4203
+ local matches = {}
4204
+ local totalMatches = 0
4205
+ local hitLimit = false
4206
+
4207
+ for _, inst in ipairs(descendants) do
4208
+ -- Type filter: pass if `inst:IsA(name)` for ANY name in typeFilter.
4209
+ local typeOk = false
4210
+ for _, t in ipairs(typeFilter) do
4211
+ local ok, isA = pcall(function() return inst:IsA(t) end)
4212
+ if ok and isA then
4213
+ typeOk = true
4214
+ break
4215
+ end
4216
+ end
4217
+ if not typeOk then continue end
4218
+
4219
+ -- Glob filter: Lua-pattern match against Name.
4220
+ if globFolded then
4221
+ local nameFolded = caseFold(inst.Name)
4222
+ if not string.find(nameFolded, globFolded) then
4223
+ continue
4224
+ end
4225
+ end
4226
+
4227
+ scanned += 1
4228
+
4229
+ -- Read Source only if applicable. Non-script instances that pass
4230
+ -- the filters are reported as name-only hits (no line content).
4231
+ local isScript = inst:IsA("LuaSourceContainer")
4232
+ local sourceOk, source = false, ""
4233
+ if isScript then
4234
+ sourceOk, source = pcall(function() return inst.Source end)
4235
+ if not sourceOk then source = "" end
4236
+ end
4237
+
4238
+ -- Apply the content pattern.
4239
+ local instMatchCount = 0
4240
+ local lineHits = nil -- only populated in "content" mode
4241
+
4242
+ if isScript and #source > 0 then
4243
+ if multiline then
4244
+ -- Whole-source match; report a single virtual line (line 1)
4245
+ -- containing the start of the match for simplicity.
4246
+ local hay = caseFold(source)
4247
+ local s, e = string.find(hay, patternFolded)
4248
+ if s then
4249
+ instMatchCount = 1
4250
+ if outputMode == "content" then
4251
+ -- For multiline mode, return the matched substring
4252
+ -- as the "line" content (truncated to keep payload sane).
4253
+ local snippet = string.sub(source, s, math.min(e, s + 500))
4254
+ lineHits = { { lineNumber = 1, content = snippet, context = {} } }
4255
+ end
4256
+ end
4257
+ else
4258
+ local lines, _ = splitLines(source)
4259
+ for lineNum, line in ipairs(lines) do
4260
+ local hay = caseFold(line)
4261
+ if string.find(hay, patternFolded) then
4262
+ instMatchCount += 1
4263
+ if outputMode == "content" then
4264
+ if not lineHits then lineHits = {} end
4265
+ local hit = {
4266
+ lineNumber = lineNum,
4267
+ content = line,
4268
+ context = {},
4269
+ }
4270
+ if contextBefore > 0 then
4271
+ for i = math.max(1, lineNum - contextBefore), lineNum - 1 do
4272
+ table.insert(hit.context, { lineNumber = i, content = lines[i], type = "before" })
4273
+ end
4274
+ end
4275
+ if contextAfter > 0 then
4276
+ for i = lineNum + 1, math.min(#lines, lineNum + contextAfter) do
4277
+ table.insert(hit.context, { lineNumber = i, content = lines[i], type = "after" })
4278
+ end
4279
+ end
4280
+ table.insert(lineHits, hit)
4281
+ end
4282
+ end
4283
+ end
4284
+ end
4285
+ end
4286
+
4287
+ -- Decide whether this instance counts as a "hit".
4288
+ -- For non-script instances that passed name/class filters, we treat
4289
+ -- them as a name-only hit so callers can use grep as a unified search.
4290
+ local isHit = (instMatchCount > 0) or (not isScript)
4291
+ if not isHit then continue end
4292
+
4293
+ totalMatches += instMatchCount
4294
+
4295
+ local entry = {
4296
+ path = getInstancePath(inst),
4297
+ className = inst.ClassName,
4298
+ name = inst.Name,
4299
+ }
4300
+ if outputMode == "content" then
4301
+ entry.lines = lineHits or {}
4302
+ elseif outputMode == "count" then
4303
+ entry.count = instMatchCount
4304
+ end
4305
+ table.insert(matches, entry)
4306
+
4307
+ if headLimit > 0 and #matches >= headLimit then
4308
+ hitLimit = true
4309
+ break
4310
+ end
4311
+ end
4312
+
4313
+ local response = {
4314
+ mode = outputMode,
4315
+ pattern = pattern,
4316
+ path = rootPath,
4317
+ scanned = scanned,
4318
+ matchCount = #matches,
4319
+ matches = matches,
4320
+ }
4321
+ if outputMode ~= "files_with_matches" then
4322
+ response.totalMatches = totalMatches
4323
+ end
4324
+ if hitLimit then
4325
+ response.truncated = true
4326
+ response.note = string.format("Output truncated at head_limit=%d. Increase head_limit or narrow with `glob`/`path`/`type`.", headLimit)
4327
+ end
4328
+ return response
4329
+ end
4330
+
3279
4331
  -- get_script_function: Extract a specific function by name
3280
4332
  handlers.getScriptFunction = function(requestData)
3281
4333
  local instancePath = requestData.instancePath
@@ -3429,6 +4481,10 @@ handlers.findAndReplaceInScripts = function(requestData)
3429
4481
  local successCount = 0
3430
4482
  local failCount = 0
3431
4483
  local skippedCount = 0
4484
+ -- Per-script pre/post-source snapshots. Used by the undo handler to
4485
+ -- revert source-only edits (direct Source = is invisible to CHS — see
4486
+ -- edit_script for the full explanation).
4487
+ local scriptEdits = {}
3432
4488
 
3433
4489
  for _, path in ipairs(paths) do
3434
4490
  local instance = getInstanceByPath(path)
@@ -3451,31 +4507,82 @@ handlers.findAndReplaceInScripts = function(requestData)
3451
4507
  else
3452
4508
  local newSource = string.gsub(source, searchStr:gsub("([^%w])", "%%%1"), replaceStr)
3453
4509
 
4510
+ -- Direct property assignment. Captured into scriptEdits so
4511
+ -- the undo handler can revert it (CHS doesn't see Source
4512
+ -- changes — see edit_script for the full rationale).
3454
4513
  if validateAfter then
3455
4514
  local isValid, syntaxErrors = validateLuaSyntax(newSource)
3456
4515
  if not isValid then
3457
4516
  table.insert(results, { path = path, success = false, error = "Would create invalid syntax", syntaxErrors = syntaxErrors })
3458
4517
  failCount = failCount + 1
3459
4518
  else
3460
- pcall(function()
3461
- ScriptEditorService:UpdateSourceAsync(instance, function() return newSource end)
4519
+ local assignOk, assignErr = pcall(function()
4520
+ instance.Source = newSource
3462
4521
  end)
3463
- table.insert(results, { path = path, success = true, replacements = occurrences })
3464
- successCount = successCount + 1
4522
+ if assignOk then
4523
+ table.insert(results, { path = path, success = true, replacements = occurrences })
4524
+ table.insert(scriptEdits, {
4525
+ instancePath = path,
4526
+ preSource = source,
4527
+ postSource = newSource,
4528
+ })
4529
+ successCount = successCount + 1
4530
+ else
4531
+ table.insert(results, { path = path, success = false, error = "Source assignment failed: " .. tostring(assignErr) })
4532
+ failCount = failCount + 1
4533
+ end
3465
4534
  end
3466
4535
  else
3467
- pcall(function()
3468
- ScriptEditorService:UpdateSourceAsync(instance, function() return newSource end)
4536
+ local assignOk, assignErr = pcall(function()
4537
+ instance.Source = newSource
3469
4538
  end)
3470
- table.insert(results, { path = path, success = true, replacements = occurrences })
3471
- successCount = successCount + 1
4539
+ if assignOk then
4540
+ table.insert(results, { path = path, success = true, replacements = occurrences })
4541
+ table.insert(scriptEdits, {
4542
+ instancePath = path,
4543
+ preSource = source,
4544
+ postSource = newSource,
4545
+ })
4546
+ successCount = successCount + 1
4547
+ else
4548
+ table.insert(results, { path = path, success = false, error = "Source assignment failed: " .. tostring(assignErr) })
4549
+ failCount = failCount + 1
4550
+ end
3472
4551
  end
3473
4552
  end
3474
4553
  end
3475
4554
  end
3476
4555
 
4556
+ -- Source edits aren't captured by ChangeHistoryService (see edit_script
4557
+ -- comment). The pre/post-source snapshots stored in details.scriptEdits
4558
+ -- are what the undo handler uses to revert. processRequest sees
4559
+ -- `_isSourceEditOnly = true` and Cancels the empty CHS recording.
4560
+
4561
+ -- Log to MCP action history for get_history visibility (only when at
4562
+ -- least one script was actually modified)
3477
4563
  if successCount > 0 then
3478
- ChangeHistoryService:SetWaypoint("Batch replace in " .. successCount .. " scripts")
4564
+ local searchPreview = oldString
4565
+ if #searchPreview > 30 then
4566
+ searchPreview = string.sub(searchPreview, 1, 30) .. "..."
4567
+ end
4568
+ local replacePreview = newString
4569
+ if #replacePreview > 30 then
4570
+ replacePreview = string.sub(replacePreview, 1, 30) .. "..."
4571
+ end
4572
+ searchPreview = searchPreview:gsub("\n", "\\n")
4573
+ replacePreview = replacePreview:gsub("\n", "\\n")
4574
+ logAction(
4575
+ "find_and_replace_in_scripts",
4576
+ tostring(successCount) .. " scripts",
4577
+ searchPreview .. " → " .. replacePreview,
4578
+ {
4579
+ total = #paths,
4580
+ successful = successCount,
4581
+ failed = failCount,
4582
+ skipped = skippedCount,
4583
+ scriptEdits = scriptEdits,
4584
+ }
4585
+ )
3479
4586
  end
3480
4587
 
3481
4588
  return {
@@ -3486,7 +4593,13 @@ handlers.findAndReplaceInScripts = function(requestData)
3486
4593
  successful = successCount,
3487
4594
  failed = failCount,
3488
4595
  skipped = skippedCount
3489
- }
4596
+ },
4597
+ -- Source-only edits: Cancel CHS (it doesn't see Source changes
4598
+ -- anyway), keep tracked log so undo can restore from snapshots.
4599
+ -- If nothing actually changed, fall back to _didNotMutate so the
4600
+ -- log entry doesn't get committed either.
4601
+ _isSourceEditOnly = (successCount > 0),
4602
+ _didNotMutate = (successCount == 0),
3490
4603
  }
3491
4604
  end
3492
4605
 
@@ -4169,28 +5282,116 @@ end
4169
5282
  -- UNDO/REDO HANDLERS (Enhanced with action tracking)
4170
5283
  -- ============================================
4171
5284
 
5285
+ -- Build a compact summary string for an action entry (used in `undone` /
5286
+ -- `redone` and in the `entries` array). Falls back gracefully when the entry
5287
+ -- came from outside MCP (action == nil).
5288
+ local function formatActionEntry(entry)
5289
+ if not entry then
5290
+ return "Unknown action (possibly manual change)"
5291
+ end
5292
+ return entry.action .. " → " .. entry.target .. " (" .. entry.summary .. ")"
5293
+ end
5294
+
5295
+ -- Helper: does this tracked entry represent a pure script-source edit?
5296
+ -- Pure script edits don't produce CHS waypoints (Studio's script editor has
5297
+ -- its own undo stack disjoint from CHS), so the entry will have an
5298
+ -- attached scriptEdits snapshot list AND no other tree/property mutation.
5299
+ -- For these we restore source manually and DO NOT call CHS:Undo() — that
5300
+ -- would skip past the next real waypoint and revert something unrelated.
5301
+ local function entryIsSourceOnly(entry)
5302
+ if not entry or not entry.details or not entry.details.scriptEdits then
5303
+ return false
5304
+ end
5305
+ return #entry.details.scriptEdits > 0
5306
+ end
5307
+
5308
+ -- Restore each script's pre-edit source. Tolerates instances that have
5309
+ -- been re-parented or destroyed since the edit (skips with a warning so
5310
+ -- one missing script doesn't poison the whole undo).
5311
+ local function restoreScriptEdits(scriptEdits, useField)
5312
+ for _, edit in ipairs(scriptEdits) do
5313
+ local instance = getInstanceByPath(edit.instancePath)
5314
+ if instance and instance:IsA("LuaSourceContainer") then
5315
+ pcall(function()
5316
+ instance.Source = edit[useField]
5317
+ end)
5318
+ end
5319
+ end
5320
+ end
5321
+
5322
+ -- Stepwise undo. Calls ChangeHistoryService:Undo() up to `count` times,
5323
+ -- popping one entry off actionHistory per step (matched into redoHistory).
5324
+ -- Studio's undo stack is the source of truth — we stop early if it runs out.
5325
+ -- `count` defaults to 1; agents pass higher values to roll back several
5326
+ -- recent operations in one call without N round-trips.
5327
+ --
5328
+ -- Special case: pure script-source edits (edit_script, find_and_replace,
5329
+ -- set_script_source via direct assignment) don't create CHS waypoints, so
5330
+ -- we restore from the snapshot in details.scriptEdits and SKIP CHS:Undo().
4172
5331
  handlers.undo = function(requestData)
5332
+ local count = tonumber(requestData and requestData.count) or 1
5333
+ if count < 1 then count = 1 end
5334
+ if count > 100 then count = 100 end -- safety clamp
5335
+
4173
5336
  local success, result = pcall(function()
4174
- -- Pop the last action from history
4175
- local lastAction = nil
4176
- if #actionHistory > 0 then
4177
- lastAction = table.remove(actionHistory)
4178
- -- Move it to redo history
4179
- table.insert(redoHistory, lastAction)
4180
- end
5337
+ local entries = {} -- actions actually undone, in undo order
5338
+ local stoppedReason = nil
5339
+
5340
+ for i = 1, count do
5341
+ -- Peek at the next tracked entry. If it's a pure source edit
5342
+ -- we MUST handle it without calling CHS:Undo() (CHS has no
5343
+ -- corresponding waypoint, so :Undo() would revert the wrong
5344
+ -- thing).
5345
+ local top = actionHistory[#actionHistory]
5346
+ if entryIsSourceOnly(top) then
5347
+ local entry = table.remove(actionHistory)
5348
+ restoreScriptEdits(entry.details.scriptEdits, "preSource")
5349
+ table.insert(redoHistory, entry)
5350
+ table.insert(entries, entry)
5351
+ else
5352
+ -- Tree/property mutation (or unknown out-of-band waypoint):
5353
+ -- defer to Studio's undo stack.
5354
+ local canUndo = ChangeHistoryService:GetCanUndo()
5355
+ if not canUndo then
5356
+ stoppedReason = "no_more_studio_undo"
5357
+ break
5358
+ end
4181
5359
 
4182
- ChangeHistoryService:Undo()
5360
+ local entry = nil
5361
+ if #actionHistory > 0 then
5362
+ entry = table.remove(actionHistory)
5363
+ table.insert(redoHistory, entry)
5364
+ end
4183
5365
 
5366
+ ChangeHistoryService:Undo()
5367
+ table.insert(entries, entry) -- may be nil for out-of-band waypoints
5368
+ end
5369
+ end
5370
+
5371
+ local primary = entries[1]
4184
5372
  return {
4185
5373
  success = true,
4186
- undone = lastAction and (lastAction.action .. " → " .. lastAction.target .. " (" .. lastAction.summary .. ")") or "Unknown action (possibly manual change)",
4187
- action = lastAction and lastAction.action or nil,
4188
- target = lastAction and lastAction.target or nil,
4189
- summary = lastAction and lastAction.summary or nil,
4190
- details = lastAction and lastAction.details or nil,
5374
+ undone_count = #entries,
5375
+ requested_count = count,
5376
+ -- Top-level fields describe the FIRST entry undone (for
5377
+ -- backward compatibility with callers expecting one undo).
5378
+ undone = formatActionEntry(primary),
5379
+ action = primary and primary.action or nil,
5380
+ target = primary and primary.target or nil,
5381
+ summary = primary and primary.summary or nil,
5382
+ details = primary and primary.details or nil,
5383
+ -- Full ordered list of entries undone this call.
5384
+ entries = entries,
4191
5385
  remaining_undos = #actionHistory,
4192
5386
  available_redos = #redoHistory,
4193
- message = lastAction and ("Undone: " .. lastAction.summary) or "Undo executed"
5387
+ stopped_early = stoppedReason ~= nil,
5388
+ stopped_reason = stoppedReason,
5389
+ message = string.format(
5390
+ "Undone %d/%d action(s)%s",
5391
+ #entries,
5392
+ count,
5393
+ stoppedReason and (" (stopped: " .. stoppedReason .. ")") or ""
5394
+ ),
4194
5395
  }
4195
5396
  end)
4196
5397
 
@@ -4201,33 +5402,68 @@ handlers.undo = function(requestData)
4201
5402
  success = false,
4202
5403
  error = "Failed to undo: " .. tostring(result),
4203
5404
  remaining_undos = #actionHistory,
4204
- available_redos = #redoHistory
5405
+ available_redos = #redoHistory,
4205
5406
  }
4206
5407
  end
4207
5408
  end
4208
5409
 
4209
5410
  handlers.redo = function(requestData)
5411
+ local count = tonumber(requestData and requestData.count) or 1
5412
+ if count < 1 then count = 1 end
5413
+ if count > 100 then count = 100 end
5414
+
4210
5415
  local success, result = pcall(function()
4211
- -- Pop the last undone action from redo history
4212
- local redoneAction = nil
4213
- if #redoHistory > 0 then
4214
- redoneAction = table.remove(redoHistory)
4215
- -- Move it back to action history
4216
- table.insert(actionHistory, redoneAction)
4217
- end
5416
+ local entries = {}
5417
+ local stoppedReason = nil
5418
+
5419
+ for i = 1, count do
5420
+ -- Mirror of undo: pure source edits redo by re-applying the
5421
+ -- postSource snapshot and SKIP CHS:Redo() (no waypoint to redo).
5422
+ local top = redoHistory[#redoHistory]
5423
+ if entryIsSourceOnly(top) then
5424
+ local entry = table.remove(redoHistory)
5425
+ restoreScriptEdits(entry.details.scriptEdits, "postSource")
5426
+ table.insert(actionHistory, entry)
5427
+ table.insert(entries, entry)
5428
+ else
5429
+ local canRedo = ChangeHistoryService:GetCanRedo()
5430
+ if not canRedo then
5431
+ stoppedReason = "no_more_studio_redo"
5432
+ break
5433
+ end
5434
+
5435
+ local entry = nil
5436
+ if #redoHistory > 0 then
5437
+ entry = table.remove(redoHistory)
5438
+ table.insert(actionHistory, entry)
5439
+ end
4218
5440
 
4219
- ChangeHistoryService:Redo()
5441
+ ChangeHistoryService:Redo()
5442
+ table.insert(entries, entry)
5443
+ end
5444
+ end
4220
5445
 
5446
+ local primary = entries[1]
4221
5447
  return {
4222
5448
  success = true,
4223
- redone = redoneAction and (redoneAction.action .. " → " .. redoneAction.target .. " (" .. redoneAction.summary .. ")") or "Unknown action",
4224
- action = redoneAction and redoneAction.action or nil,
4225
- target = redoneAction and redoneAction.target or nil,
4226
- summary = redoneAction and redoneAction.summary or nil,
4227
- details = redoneAction and redoneAction.details or nil,
5449
+ redone_count = #entries,
5450
+ requested_count = count,
5451
+ redone = formatActionEntry(primary),
5452
+ action = primary and primary.action or nil,
5453
+ target = primary and primary.target or nil,
5454
+ summary = primary and primary.summary or nil,
5455
+ details = primary and primary.details or nil,
5456
+ entries = entries,
4228
5457
  remaining_undos = #actionHistory,
4229
5458
  available_redos = #redoHistory,
4230
- message = redoneAction and ("Redone: " .. redoneAction.summary) or "Redo executed"
5459
+ stopped_early = stoppedReason ~= nil,
5460
+ stopped_reason = stoppedReason,
5461
+ message = string.format(
5462
+ "Redone %d/%d action(s)%s",
5463
+ #entries,
5464
+ count,
5465
+ stoppedReason and (" (stopped: " .. stoppedReason .. ")") or ""
5466
+ ),
4231
5467
  }
4232
5468
  end)
4233
5469
 
@@ -4238,11 +5474,68 @@ handlers.redo = function(requestData)
4238
5474
  success = false,
4239
5475
  error = "Failed to redo: " .. tostring(result),
4240
5476
  remaining_undos = #actionHistory,
4241
- available_redos = #redoHistory
5477
+ available_redos = #redoHistory,
4242
5478
  }
4243
5479
  end
4244
5480
  end
4245
5481
 
5482
+ -- ============================================
5483
+ -- HISTORY INTROSPECTION
5484
+ -- ============================================
5485
+
5486
+ -- Read-only view of the recent action history + Studio's authoritative
5487
+ -- can_undo/can_redo flags. Useful for agents that want to peek before calling
5488
+ -- undo/redo (e.g. "what am I about to undo?") or to recover state after a
5489
+ -- session resume.
5490
+ handlers.getHistory = function(requestData)
5491
+ local limit = tonumber(requestData and requestData.limit) or 20
5492
+ if limit < 1 then limit = 1 end
5493
+ if limit > MAX_ACTION_HISTORY then limit = MAX_ACTION_HISTORY end
5494
+ local includeDetails = requestData and requestData.includeDetails == true
5495
+
5496
+ local now = os.time()
5497
+
5498
+ -- Slice the top `limit` entries of each stack. Index 0 = the one that
5499
+ -- would be popped next (top of stack); larger indices reach further back.
5500
+ local function sliceStack(stack)
5501
+ local out = {}
5502
+ local n = #stack
5503
+ local take = math.min(limit, n)
5504
+ for i = 0, take - 1 do
5505
+ local entry = stack[n - i]
5506
+ if entry then
5507
+ local sliced = {
5508
+ index = i,
5509
+ action = entry.action,
5510
+ target = entry.target,
5511
+ summary = entry.summary,
5512
+ timestamp = entry.timestamp,
5513
+ age_seconds = now - (entry.timestamp or now),
5514
+ }
5515
+ if includeDetails then
5516
+ sliced.details = entry.details
5517
+ end
5518
+ table.insert(out, sliced)
5519
+ end
5520
+ end
5521
+ return out
5522
+ end
5523
+
5524
+ local canUndo = ChangeHistoryService:GetCanUndo()
5525
+ local canRedo = ChangeHistoryService:GetCanRedo()
5526
+
5527
+ return {
5528
+ success = true,
5529
+ can_undo = canUndo,
5530
+ can_redo = canRedo,
5531
+ tracked_undo_count = #actionHistory,
5532
+ tracked_redo_count = #redoHistory,
5533
+ max_history = MAX_ACTION_HISTORY,
5534
+ undo_stack = sliceStack(actionHistory),
5535
+ redo_stack = sliceStack(redoHistory),
5536
+ }
5537
+ end
5538
+
4246
5539
  -- ============================================
4247
5540
  -- EXECUTE LUA HANDLER (Run arbitrary Lua code)
4248
5541
  -- ============================================
@@ -4453,11 +5746,31 @@ handlers.executeLua = function(requestData)
4453
5746
  end
4454
5747
  end
4455
5748
 
5749
+ -- Log to MCP action history for get_history visibility. We can't know
5750
+ -- whether arbitrary user code actually mutated the DataModel, so we
5751
+ -- tag the result with `_mutatesUnknown=true` — processRequest uses
5752
+ -- this sentinel to be conservative: if CHS state didn't visibly
5753
+ -- advance (no canUndo flip, no canRedo clear), the pending log gets
5754
+ -- DISCARDED instead of polluting actionHistory with a phantom entry.
5755
+ local codeLines = 1
5756
+ for _ in string.gmatch(code, "\n") do
5757
+ codeLines = codeLines + 1
5758
+ end
5759
+ local codePreview = code:gsub("^%s+", ""):gsub("\n.*$", "")
5760
+ if #codePreview > 60 then
5761
+ codePreview = string.sub(codePreview, 1, 60) .. "..."
5762
+ end
5763
+ logAction("execute_lua", "<inline>", codePreview, {
5764
+ code_length = #code,
5765
+ line_count = codeLines,
5766
+ })
5767
+
4456
5768
  return {
4457
5769
  success = true,
4458
5770
  result = serializeResult(execResult),
4459
5771
  resultType = typeof(execResult),
4460
- message = "Code executed successfully"
5772
+ message = "Code executed successfully",
5773
+ _mutatesUnknown = true, -- stripped by processRequest
4461
5774
  }
4462
5775
  end
4463
5776
 
@@ -4609,7 +5922,7 @@ handlers.playSolo = function(requestData)
4609
5922
  cleanupTestCompanions()
4610
5923
 
4611
5924
  local success, result = pcall(function()
4612
- local companion = injectTestCompanion(pluginState.serverUrl, sessionId)
5925
+ local serverCompanion, clientCompanion = injectTestCompanion(pluginState.serverUrl, sessionId)
4613
5926
 
4614
5927
  -- ExecutePlayModeAsync yields until the test ends. Run it on its
4615
5928
  -- own thread so the HTTP response for play_solo returns now,
@@ -4646,8 +5959,9 @@ handlers.playSolo = function(requestData)
4646
5959
  success = true,
4647
5960
  sessionId = sessionId,
4648
5961
  method = "StudioTestService:ExecutePlayModeAsync",
4649
- companionPath = getInstancePath(companion),
4650
- message = "Started Play Solo with MCP companion. Use stop_play to end, get_playtest_output to read logs.",
5962
+ companionPath = getInstancePath(serverCompanion),
5963
+ clientCompanionPath = clientCompanion and getInstancePath(clientCompanion) or nil,
5964
+ message = "Started Play Solo with MCP server + client companions. Use stop_play to end, get_playtest_output to read logs, run_live_lua to execute code in the running test.",
4651
5965
  }
4652
5966
  end)
4653
5967
 
@@ -5375,6 +6689,366 @@ handlers.renderObjectView = function(requestData)
5375
6689
  end
5376
6690
  end
5377
6691
 
6692
+ -- ============================================
6693
+ -- GUI RENDERING SYSTEM
6694
+ -- ============================================
6695
+
6696
+ -- render_gui: capture a 2D GUI to an image. ViewportFrame is 3D-only, so we
6697
+ -- clone the GUI into an off-screen ScreenGui in CoreGui, take a full-screen
6698
+ -- CaptureService screenshot, then crop.
6699
+ --
6700
+ -- region:
6701
+ -- "element" (default) → crop tight to the target's on-screen rect.
6702
+ -- "screen" → return the whole viewport, so you can verify where
6703
+ -- the element actually lands relative to the screen.
6704
+ --
6705
+ -- Placement is faithful: when the target lives under a ScreenGui we clone the
6706
+ -- WHOLE ScreenGui (preserving siblings, layout, and GUI inset) and locate the
6707
+ -- cloned target by its child-index path, instead of reparenting the element to
6708
+ -- (0,0). StarterGui elements report AbsolutePosition = 0 until rendered in their
6709
+ -- real context, so this is what makes screen-mode placement accurate.
6710
+ handlers.renderGui = function(requestData)
6711
+ local CaptureService = game:GetService("CaptureService")
6712
+ local AssetService = game:GetService("AssetService")
6713
+ local CoreGui = game:GetService("CoreGui")
6714
+
6715
+ -- Walk up to the nearest ScreenGui ancestor (2D overlay LayerCollector).
6716
+ local function findAncestorScreenGui(inst)
6717
+ local node = inst.Parent
6718
+ while node do
6719
+ if node:IsA("ScreenGui") then
6720
+ return node
6721
+ end
6722
+ node = node.Parent
6723
+ end
6724
+ return nil
6725
+ end
6726
+
6727
+ -- Child-index path from ancestor down to descendant (1-based GetChildren idx).
6728
+ local function indexPath(ancestor, descendant)
6729
+ local path = {}
6730
+ local node = descendant
6731
+ while node and node ~= ancestor do
6732
+ local parent = node.Parent
6733
+ if not parent then
6734
+ return nil
6735
+ end
6736
+ local idx = nil
6737
+ local siblings = parent:GetChildren()
6738
+ for i = 1, #siblings do
6739
+ if siblings[i] == node then
6740
+ idx = i
6741
+ break
6742
+ end
6743
+ end
6744
+ if not idx then
6745
+ return nil
6746
+ end
6747
+ table.insert(path, 1, idx)
6748
+ node = parent
6749
+ end
6750
+ if node ~= ancestor then
6751
+ return nil
6752
+ end
6753
+ return path
6754
+ end
6755
+
6756
+ -- Follow a child-index path from root (clone order matches the original).
6757
+ local function followPath(root, path)
6758
+ local node = root
6759
+ for _, idx in ipairs(path) do
6760
+ local children = node:GetChildren()
6761
+ node = children[idx]
6762
+ if not node then
6763
+ return nil
6764
+ end
6765
+ end
6766
+ return node
6767
+ end
6768
+
6769
+ local success, result = pcall(function()
6770
+ -- ──────────── parse params ────────────
6771
+ local instancePath = requestData.instancePath
6772
+ if not instancePath then
6773
+ return { success = false, error = "instancePath is required" }
6774
+ end
6775
+
6776
+ local maxWidth = tonumber(requestData.maxWidth)
6777
+ local maxHeight = tonumber(requestData.maxHeight)
6778
+ local region = (requestData.region == "screen") and "screen" or "element"
6779
+
6780
+ -- ──────────── resolve target ────────────
6781
+ local target = getInstanceByPath(instancePath)
6782
+ if not target then
6783
+ return { success = false, error = "Instance not found: " .. instancePath }
6784
+ end
6785
+
6786
+ -- ──────────── build off-screen host ────────────
6787
+ -- hostGui is whatever we parent into CoreGui; cropElements drive the
6788
+ -- element-mode bounding box; placement records how we positioned things.
6789
+ local hostGui
6790
+ local cropElements = {}
6791
+ local placement = "synthetic"
6792
+
6793
+ if target:IsA("GuiObject") then
6794
+ local ancestorGui = findAncestorScreenGui(target)
6795
+ local path = ancestorGui and indexPath(ancestorGui, target) or nil
6796
+
6797
+ if ancestorGui and path then
6798
+ -- Faithful: clone the whole ScreenGui so siblings/layout/inset
6799
+ -- are intact, then find the cloned target via its index path.
6800
+ local clonedGui = ancestorGui:Clone()
6801
+ clonedGui.Name = "MCPRenderGui"
6802
+ clonedGui.Enabled = true
6803
+ clonedGui.DisplayOrder = 2147483647
6804
+ local clonedTarget = followPath(clonedGui, path)
6805
+ if not clonedTarget then
6806
+ clonedGui:Destroy()
6807
+ return {
6808
+ success = false,
6809
+ error = "Failed to locate cloned target inside ScreenGui clone",
6810
+ }
6811
+ end
6812
+ clonedTarget.Visible = true
6813
+ hostGui = clonedGui
6814
+ table.insert(cropElements, clonedTarget)
6815
+ placement = "faithful"
6816
+ else
6817
+ -- No ScreenGui context (e.g. element parked in ReplicatedStorage):
6818
+ -- drop a synthetic host and park the clone at the origin.
6819
+ hostGui = Instance.new("ScreenGui")
6820
+ hostGui.Name = "MCPRenderGui"
6821
+ hostGui.IgnoreGuiInset = true
6822
+ hostGui.ResetOnSpawn = false
6823
+ hostGui.DisplayOrder = 2147483647
6824
+ hostGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
6825
+
6826
+ local cloned = target:Clone()
6827
+ cloned.Visible = true
6828
+ cloned.AnchorPoint = Vector2.new(0, 0)
6829
+ cloned.Position = UDim2.fromOffset(0, 0)
6830
+ cloned.Parent = hostGui
6831
+ table.insert(cropElements, cloned)
6832
+ end
6833
+ elseif target:IsA("ScreenGui") then
6834
+ -- Faithful: render the whole ScreenGui as it would appear.
6835
+ hostGui = target:Clone()
6836
+ hostGui.Name = "MCPRenderGui"
6837
+ hostGui.Enabled = true
6838
+ hostGui.DisplayOrder = 2147483647
6839
+ placement = "faithful"
6840
+ for _, child in ipairs(hostGui:GetChildren()) do
6841
+ if child:IsA("GuiObject") then
6842
+ child.Visible = true
6843
+ table.insert(cropElements, child)
6844
+ end
6845
+ end
6846
+ if #cropElements == 0 then
6847
+ hostGui:Destroy()
6848
+ return {
6849
+ success = false,
6850
+ error = "GUI has no GuiObject children to render: " .. instancePath,
6851
+ }
6852
+ end
6853
+ elseif target:IsA("LayerCollector") then
6854
+ -- SurfaceGui / BillboardGui render in 3D, not as a 2D overlay, so we
6855
+ -- can't place them faithfully — copy their children into a synthetic
6856
+ -- ScreenGui host instead.
6857
+ hostGui = Instance.new("ScreenGui")
6858
+ hostGui.Name = "MCPRenderGui"
6859
+ hostGui.IgnoreGuiInset = true
6860
+ hostGui.ResetOnSpawn = false
6861
+ hostGui.DisplayOrder = 2147483647
6862
+ hostGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
6863
+ for _, child in ipairs(target:GetChildren()) do
6864
+ if child:IsA("GuiObject") then
6865
+ local cloned = child:Clone()
6866
+ cloned.Visible = true
6867
+ cloned.Parent = hostGui
6868
+ table.insert(cropElements, cloned)
6869
+ end
6870
+ end
6871
+ if #cropElements == 0 then
6872
+ hostGui:Destroy()
6873
+ return {
6874
+ success = false,
6875
+ error = "GUI has no GuiObject children to render: " .. instancePath,
6876
+ }
6877
+ end
6878
+ else
6879
+ return {
6880
+ success = false,
6881
+ error = "render_gui target must be a GuiObject or a ScreenGui/SurfaceGui/BillboardGui, got "
6882
+ .. target.ClassName,
6883
+ }
6884
+ end
6885
+
6886
+ hostGui.Parent = CoreGui
6887
+
6888
+ -- Let layout / AbsolutePosition settle.
6889
+ RunService.Heartbeat:Wait()
6890
+ RunService.Heartbeat:Wait()
6891
+
6892
+ -- ──────────── union bounding box (screen px) ────────────
6893
+ local minX, minY = math.huge, math.huge
6894
+ local maxX, maxY = -math.huge, -math.huge
6895
+ for _, el in ipairs(cropElements) do
6896
+ local pos = el.AbsolutePosition
6897
+ local size = el.AbsoluteSize
6898
+ minX = math.min(minX, pos.X)
6899
+ minY = math.min(minY, pos.Y)
6900
+ maxX = math.max(maxX, pos.X + size.X)
6901
+ maxY = math.max(maxY, pos.Y + size.Y)
6902
+ end
6903
+
6904
+ -- ──────────── capture ────────────
6905
+ local captureComplete = Instance.new("BindableEvent")
6906
+ local captureResult, captureError = nil, nil
6907
+
6908
+ local pcOk, pcErr = pcall(function()
6909
+ CaptureService:CaptureScreenshot(function(contentId)
6910
+ if contentId then
6911
+ captureResult = contentId
6912
+ else
6913
+ captureError = "CaptureScreenshot returned nil"
6914
+ end
6915
+ captureComplete:Fire()
6916
+ end)
6917
+ end)
6918
+
6919
+ if not pcOk then
6920
+ hostGui:Destroy()
6921
+ return { success = false, error = "CaptureScreenshot threw: " .. tostring(pcErr) }
6922
+ end
6923
+
6924
+ local timeoutThread = task.delay(8, function()
6925
+ if not captureResult and not captureError then
6926
+ captureError = "Capture timed out (8s)"
6927
+ captureComplete:Fire()
6928
+ end
6929
+ end)
6930
+ captureComplete.Event:Wait()
6931
+ task.cancel(timeoutThread)
6932
+ captureComplete:Destroy()
6933
+
6934
+ hostGui:Destroy()
6935
+
6936
+ if captureError then
6937
+ return { success = false, error = "Failed to capture screen: " .. captureError }
6938
+ end
6939
+
6940
+ -- ──────────── crop ────────────
6941
+ local sourceImage = AssetService:CreateEditableImageAsync(Content.fromUri(tostring(captureResult)))
6942
+ local sourceSize = sourceImage.Size
6943
+
6944
+ local cropX, cropY, cropW, cropH
6945
+ if region == "screen" then
6946
+ -- Whole viewport: keep the element in its real on-screen context.
6947
+ cropX, cropY = 0, 0
6948
+ cropW, cropH = sourceSize.X, sourceSize.Y
6949
+ else
6950
+ -- Tight crop to the element's on-screen rect.
6951
+ local rx = math.max(math.floor(minX), 0)
6952
+ local ry = math.max(math.floor(minY), 0)
6953
+ local rw = math.max(math.ceil(maxX - rx), 1)
6954
+ local rh = math.max(math.ceil(maxY - ry), 1)
6955
+ cropX = math.clamp(rx, 0, math.max(sourceSize.X - 1, 0))
6956
+ cropY = math.clamp(ry, 0, math.max(sourceSize.Y - 1, 0))
6957
+ cropW = math.clamp(rw, 1, sourceSize.X - cropX)
6958
+ cropH = math.clamp(rh, 1, sourceSize.Y - cropY)
6959
+ end
6960
+
6961
+ local croppedImage = AssetService:CreateEditableImage({ Size = Vector2.new(cropW, cropH) })
6962
+ croppedImage:DrawImageTransformed(
6963
+ Vector2.new(cropW / 2, cropH / 2),
6964
+ Vector2.new(1, 1),
6965
+ 0,
6966
+ sourceImage,
6967
+ {
6968
+ CombineType = Enum.ImageCombineType.Overwrite,
6969
+ PivotPoint = Vector2.new(cropX + cropW / 2, cropY + cropH / 2),
6970
+ }
6971
+ )
6972
+ sourceImage:Destroy()
6973
+
6974
+ -- ──────────── downscale (aspect preserved, no upscale) ────────────
6975
+ -- Two independent reasons to shrink, folded into ONE resize pass:
6976
+ -- 1. Caller's optional maxWidth/maxHeight clamp.
6977
+ -- 2. The HTTP-bridge raw-pixel cap (MAX_RAW_PIXEL_BYTES) — mirrors
6978
+ -- captureScreenshot. region="screen" on a hi-DPI display can blow
6979
+ -- past the cap, so this guard is what keeps the POST safe.
6980
+ local scaleW = maxWidth and (maxWidth / cropW) or 1
6981
+ local scaleH = maxHeight and (maxHeight / cropH) or 1
6982
+ local scale = math.min(scaleW, scaleH, 1)
6983
+ local outW = math.max(math.floor(cropW * scale), 1)
6984
+ local outH = math.max(math.floor(cropH * scale), 1)
6985
+
6986
+ local autoShrunk = false
6987
+ local requiredBytes = outW * outH * 4
6988
+ if requiredBytes > MAX_RAW_PIXEL_BYTES then
6989
+ local shrink = math.sqrt(MAX_RAW_PIXEL_BYTES / requiredBytes)
6990
+ outW = math.max(math.floor(outW * shrink), 1)
6991
+ outH = math.max(math.floor(outH * shrink), 1)
6992
+ scale = scale * shrink
6993
+ autoShrunk = true
6994
+ end
6995
+
6996
+ local outputImage = croppedImage
6997
+ if (outW ~= cropW) or (outH ~= cropH) then
6998
+ outputImage = AssetService:CreateEditableImage({ Size = Vector2.new(outW, outH) })
6999
+ outputImage:DrawImageTransformed(
7000
+ Vector2.new(outW / 2, outH / 2),
7001
+ Vector2.new(scale, scale),
7002
+ 0,
7003
+ croppedImage,
7004
+ {
7005
+ CombineType = Enum.ImageCombineType.Overwrite,
7006
+ PivotPoint = Vector2.new(cropW / 2, cropH / 2),
7007
+ }
7008
+ )
7009
+ croppedImage:Destroy()
7010
+ end
7011
+
7012
+ local rgbaBuffer = outputImage:ReadPixelsBuffer(Vector2.new(0, 0), outputImage.Size)
7013
+ local rgbaString = buffer.tostring(rgbaBuffer)
7014
+ local base64Data = base64encode(rgbaString)
7015
+ outputImage:Destroy()
7016
+
7017
+ return {
7018
+ success = true,
7019
+ base64 = base64Data,
7020
+ width = outW,
7021
+ height = outH,
7022
+ format = "RGBA",
7023
+ encoding = "base64",
7024
+ guiInfo = {
7025
+ objectName = target.Name,
7026
+ objectClass = target.ClassName,
7027
+ renderedElements = #cropElements,
7028
+ region = region,
7029
+ placement = placement,
7030
+ autoShrunk = autoShrunk,
7031
+ rect = { x = cropX, y = cropY, width = cropW, height = cropH },
7032
+ sourceWidth = sourceSize.X,
7033
+ sourceHeight = sourceSize.Y,
7034
+ },
7035
+ message = string.format(
7036
+ "Rendered GUI %s at %dx%d (region=%s, placement=%s, from %dx%d screen)",
7037
+ target.Name, outW, outH, region, placement, sourceSize.X, sourceSize.Y
7038
+ ),
7039
+ }
7040
+ end)
7041
+
7042
+ if success then
7043
+ return result
7044
+ else
7045
+ return {
7046
+ success = false,
7047
+ error = "Failed to render GUI: " .. tostring(result),
7048
+ }
7049
+ end
7050
+ end
7051
+
5378
7052
  -- ============================================
5379
7053
  -- CAMERA CONTROL SYSTEM
5380
7054
  -- ============================================
@@ -5512,12 +7186,11 @@ endpointHandlers = {
5512
7186
  ["/api/set-relative-property"] = handlers.setRelativeProperty,
5513
7187
  ["/api/get-script-source"] = handlers.getScriptSource,
5514
7188
  ["/api/set-script-source"] = handlers.setScriptSource,
5515
- ["/api/edit-script-lines"] = handlers.editScriptLines,
5516
- ["/api/insert-script-lines"] = handlers.insertScriptLines,
5517
- ["/api/delete-script-lines"] = handlers.deleteScriptLines,
5518
7189
  -- Claude Code-style script editing tools
5519
7190
  ["/api/edit-script"] = handlers.editScript,
5520
7191
  ["/api/search-script"] = handlers.searchScript,
7192
+ -- Claude Code-style grep across the entire instance tree
7193
+ ["/api/grep"] = handlers.grep,
5521
7194
  ["/api/get-script-function"] = handlers.getScriptFunction,
5522
7195
  ["/api/find-and-replace-in-scripts"] = handlers.findAndReplaceInScripts,
5523
7196
  ["/api/get-attribute"] = handlers.getAttribute,
@@ -5536,6 +7209,7 @@ endpointHandlers = {
5536
7209
  ["/api/insert-asset"] = handlers.insertAsset,
5537
7210
  ["/api/undo"] = handlers.undo,
5538
7211
  ["/api/redo"] = handlers.redo,
7212
+ ["/api/get-history"] = handlers.getHistory,
5539
7213
  -- Execute Lua
5540
7214
  ["/api/execute-lua"] = handlers.executeLua,
5541
7215
  -- Playtest control
@@ -5545,6 +7219,8 @@ endpointHandlers = {
5545
7219
  ["/api/capture-screenshot"] = handlers.captureScreenshot,
5546
7220
  -- ViewportFrame rendering
5547
7221
  ["/api/render-object-view"] = handlers.renderObjectView,
7222
+ -- 2D GUI rendering
7223
+ ["/api/render-gui"] = handlers.renderGui,
5548
7224
  -- Camera control
5549
7225
  ["/api/focus-camera"] = handlers.focusCamera,
5550
7226
  }