@chrrxs/robloxstudio-mcp-inspector 2.9.0 → 2.10.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.
- package/dist/index.js +130 -14
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +508 -127
- package/studio-plugin/MCPPlugin.rbxmx +508 -127
- package/studio-plugin/src/modules/ClientBroker.ts +125 -48
- package/studio-plugin/src/modules/Communication.ts +59 -26
- package/studio-plugin/src/modules/EvalBridges.ts +15 -3
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +138 -0
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +15 -0
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +57 -2
- package/studio-plugin/src/server/index.server.ts +5 -0
- package/studio-plugin/src/types/index.d.ts +4 -0
|
@@ -10,6 +10,11 @@ local State = TS.import(script, script, "modules", "State")
|
|
|
10
10
|
local UI = TS.import(script, script, "modules", "UI")
|
|
11
11
|
local Communication = TS.import(script, script, "modules", "Communication")
|
|
12
12
|
local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
|
|
13
|
+
local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
|
|
14
|
+
-- Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
15
|
+
-- boot-time prints from the user's place scripts are captured. Powers the
|
|
16
|
+
-- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
|
|
17
|
+
RuntimeLogBuffer.install()
|
|
13
18
|
UI.init(plugin)
|
|
14
19
|
local elements = UI.getElements()
|
|
15
20
|
local toolbar = plugin:CreateToolbar("MCP Inspector")
|
|
@@ -72,6 +77,7 @@ local HttpService = _services.HttpService
|
|
|
72
77
|
local Players = _services.Players
|
|
73
78
|
local ReplicatedStorage = _services.ReplicatedStorage
|
|
74
79
|
local RunService = _services.RunService
|
|
80
|
+
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
75
81
|
-- The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
76
82
|
-- HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
77
83
|
-- HttpEnabled reads as false there regardless of identity. So the server peer
|
|
@@ -87,6 +93,37 @@ local RunService = _services.RunService
|
|
|
87
93
|
-- edit DM untouched.
|
|
88
94
|
local MCP_URL = "http://localhost:58741"
|
|
89
95
|
local BROKER_NAME = "__MCPClientBroker"
|
|
96
|
+
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
97
|
+
-- Each requires the client peer's plugin VM (because the buffer / require
|
|
98
|
+
-- cache / etc. lives there) so the server peer alone can't satisfy them.
|
|
99
|
+
local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
|
|
100
|
+
["/api/execute-luau"] = true,
|
|
101
|
+
["/api/get-runtime-logs"] = true,
|
|
102
|
+
}
|
|
103
|
+
-- Throttle re-ready calls per proxyId so a brief window of unknownInstance
|
|
104
|
+
-- polls doesn't cause a re-register stampede.
|
|
105
|
+
local lastReadyByProxy = {}
|
|
106
|
+
local postJson
|
|
107
|
+
local function reRegisterProxy(proxyId, role)
|
|
108
|
+
local now = tick()
|
|
109
|
+
local _proxyId = proxyId
|
|
110
|
+
local _condition = lastReadyByProxy[_proxyId]
|
|
111
|
+
if _condition == nil then
|
|
112
|
+
_condition = 0
|
|
113
|
+
end
|
|
114
|
+
local last = _condition
|
|
115
|
+
if now - last < 2 then
|
|
116
|
+
return nil
|
|
117
|
+
end
|
|
118
|
+
local _proxyId_1 = proxyId
|
|
119
|
+
lastReadyByProxy[_proxyId_1] = now
|
|
120
|
+
pcall(function()
|
|
121
|
+
return postJson("/ready", {
|
|
122
|
+
instanceId = proxyId,
|
|
123
|
+
role = role,
|
|
124
|
+
})
|
|
125
|
+
end)
|
|
126
|
+
end
|
|
90
127
|
local function forkRole()
|
|
91
128
|
if not RunService:IsRunning() then
|
|
92
129
|
return "edit"
|
|
@@ -96,7 +133,7 @@ local function forkRole()
|
|
|
96
133
|
end
|
|
97
134
|
return "client"
|
|
98
135
|
end
|
|
99
|
-
|
|
136
|
+
function postJson(endpoint, body)
|
|
100
137
|
return pcall(function()
|
|
101
138
|
return HttpService:RequestAsync({
|
|
102
139
|
Url = `{MCP_URL}{endpoint}`,
|
|
@@ -108,6 +145,56 @@ local function postJson(endpoint, body)
|
|
|
108
145
|
})
|
|
109
146
|
end)
|
|
110
147
|
end
|
|
148
|
+
local function handleExecuteLuau(data)
|
|
149
|
+
local code = data and (data.code)
|
|
150
|
+
if type(code) == "string" == false or code == "" then
|
|
151
|
+
return {
|
|
152
|
+
success = false,
|
|
153
|
+
error = "code is required",
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
local m = Instance.new("ModuleScript")
|
|
157
|
+
m.Name = "__MCPClientEval"
|
|
158
|
+
local okSet, setErr = pcall(function()
|
|
159
|
+
m.Source = code
|
|
160
|
+
end)
|
|
161
|
+
if not okSet then
|
|
162
|
+
m:Destroy()
|
|
163
|
+
return {
|
|
164
|
+
success = false,
|
|
165
|
+
error = `Source set failed: {tostring(setErr)}`,
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
m.Parent = game.Workspace
|
|
169
|
+
local okReq, result = pcall(function()
|
|
170
|
+
return require(m)
|
|
171
|
+
end)
|
|
172
|
+
m:Destroy()
|
|
173
|
+
if okReq then
|
|
174
|
+
return {
|
|
175
|
+
success = true,
|
|
176
|
+
returnValue = if result ~= nil then tostring(result) else nil,
|
|
177
|
+
message = "Code executed successfully",
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
return {
|
|
181
|
+
success = false,
|
|
182
|
+
error = tostring(result),
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
local function handleGetRuntimeLogs(data)
|
|
186
|
+
local d = data or {}
|
|
187
|
+
local since = d.since
|
|
188
|
+
local tail = d.tail
|
|
189
|
+
local filter = d.filter
|
|
190
|
+
-- "client" is the generic peer tag; MCP-side aggregator overrides with
|
|
191
|
+
-- the specific role (e.g. "client-1") on target=all fan-out.
|
|
192
|
+
return RuntimeLogBuffer.query({
|
|
193
|
+
since = since,
|
|
194
|
+
tail = tail,
|
|
195
|
+
filter = filter,
|
|
196
|
+
}, "client")
|
|
197
|
+
end
|
|
111
198
|
local function setupClientBroker()
|
|
112
199
|
local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
|
|
113
200
|
if not rf or not rf:IsA("RemoteFunction") then
|
|
@@ -115,44 +202,20 @@ local function setupClientBroker()
|
|
|
115
202
|
return nil
|
|
116
203
|
end
|
|
117
204
|
rf.OnClientInvoke = function(payload)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
205
|
+
-- Two payload shapes in the wild:
|
|
206
|
+
-- - {endpoint, data} from v2.10+ server-peer broker (this is the new
|
|
207
|
+
-- discriminated form that lets us dispatch on endpoint)
|
|
208
|
+
-- - {code} from pre-v2.10 server-peer broker (raw execute-luau payload)
|
|
209
|
+
-- The shapes coexist gracefully because we fall back to execute-luau
|
|
210
|
+
-- when endpoint is missing.
|
|
211
|
+
if payload and payload.endpoint == "/api/get-runtime-logs" then
|
|
212
|
+
return handleGetRuntimeLogs(payload.data)
|
|
124
213
|
end
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
local okSet, setErr = pcall(function()
|
|
128
|
-
m.Source = code
|
|
129
|
-
end)
|
|
130
|
-
if not okSet then
|
|
131
|
-
m:Destroy()
|
|
132
|
-
local _arg0 = {
|
|
133
|
-
success = false,
|
|
134
|
-
error = `Source set failed: {tostring(setErr)}`,
|
|
135
|
-
}
|
|
136
|
-
return _arg0
|
|
137
|
-
end
|
|
138
|
-
m.Parent = game.Workspace
|
|
139
|
-
local okReq, result = pcall(function()
|
|
140
|
-
return require(m)
|
|
141
|
-
end)
|
|
142
|
-
m:Destroy()
|
|
143
|
-
if okReq then
|
|
144
|
-
local _arg0 = {
|
|
145
|
-
success = true,
|
|
146
|
-
returnValue = if result ~= nil then tostring(result) else nil,
|
|
147
|
-
message = "Code executed successfully",
|
|
148
|
-
}
|
|
149
|
-
return _arg0
|
|
214
|
+
if payload and payload.endpoint == "/api/execute-luau" then
|
|
215
|
+
return handleExecuteLuau(payload.data)
|
|
150
216
|
end
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
error = tostring(result),
|
|
154
|
-
}
|
|
155
|
-
return _arg0
|
|
217
|
+
-- Legacy: raw execute-luau payload at the top level.
|
|
218
|
+
return handleExecuteLuau(payload)
|
|
156
219
|
end
|
|
157
220
|
end
|
|
158
221
|
local proxyByPlayer = {}
|
|
@@ -179,33 +242,47 @@ local function pollProxy(proxyId, player, rf)
|
|
|
179
242
|
local okJson, body = pcall(function()
|
|
180
243
|
return HttpService:JSONDecode(res.Body)
|
|
181
244
|
end)
|
|
182
|
-
if okJson and body
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
245
|
+
if okJson and body then
|
|
246
|
+
-- Server lost our proxy registration (process restart, etc.) -
|
|
247
|
+
-- re-register so the next poll cycle starts routing again.
|
|
248
|
+
if body.knownInstance == false then
|
|
249
|
+
reRegisterProxy(proxyId, "client")
|
|
250
|
+
end
|
|
251
|
+
if body.request and body.requestId ~= nil then
|
|
252
|
+
local request = body.request
|
|
253
|
+
local response
|
|
254
|
+
local _endpoint = request.endpoint
|
|
255
|
+
if CLIENT_BROKER_ALLOWED_ENDPOINTS[_endpoint] ~= nil then
|
|
256
|
+
-- Forward as a discriminated envelope so the client-side
|
|
257
|
+
-- OnClientInvoke knows which endpoint it's serving.
|
|
258
|
+
local envelope = {
|
|
259
|
+
endpoint = request.endpoint,
|
|
260
|
+
data = request.data,
|
|
193
261
|
}
|
|
262
|
+
local okInvoke, invokeRes = pcall(function()
|
|
263
|
+
return rf:InvokeClient(player, envelope)
|
|
264
|
+
end)
|
|
265
|
+
if okInvoke then
|
|
266
|
+
response = if invokeRes ~= nil then invokeRes else {
|
|
267
|
+
success = false,
|
|
268
|
+
error = "nil response",
|
|
269
|
+
}
|
|
270
|
+
else
|
|
271
|
+
response = {
|
|
272
|
+
success = false,
|
|
273
|
+
error = `InvokeClient failed: {tostring(invokeRes)}`,
|
|
274
|
+
}
|
|
275
|
+
end
|
|
194
276
|
else
|
|
195
277
|
response = {
|
|
196
|
-
|
|
197
|
-
error = `InvokeClient failed: {tostring(invokeRes)}`,
|
|
278
|
+
error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
|
|
198
279
|
}
|
|
199
280
|
end
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
281
|
+
postJson("/response", {
|
|
282
|
+
requestId = body.requestId,
|
|
283
|
+
response = response,
|
|
284
|
+
})
|
|
204
285
|
end
|
|
205
|
-
postJson("/response", {
|
|
206
|
-
requestId = body.requestId,
|
|
207
|
-
response = response,
|
|
208
|
-
})
|
|
209
286
|
end
|
|
210
287
|
end
|
|
211
288
|
task.wait(0.5)
|
|
@@ -264,22 +341,28 @@ local function startEditProxyLoop()
|
|
|
264
341
|
local okJson, body = pcall(function()
|
|
265
342
|
return HttpService:JSONDecode(pollRes.Body)
|
|
266
343
|
end)
|
|
267
|
-
if okJson and body
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
344
|
+
if okJson and body then
|
|
345
|
+
-- Re-register if the server lost our edit-proxy registration.
|
|
346
|
+
if body.knownInstance == false then
|
|
347
|
+
reRegisterProxy(proxyId, "edit-proxy")
|
|
348
|
+
end
|
|
349
|
+
if body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
|
|
350
|
+
local sts = game:GetService("StudioTestService")
|
|
351
|
+
local endOk, endErr = pcall(function()
|
|
352
|
+
return sts:EndTest("stopped_by_mcp")
|
|
353
|
+
end)
|
|
354
|
+
local response = if endOk then {
|
|
355
|
+
success = true,
|
|
356
|
+
message = "Playtest stopped via edit-proxy/EndTest",
|
|
357
|
+
} else {
|
|
358
|
+
success = false,
|
|
359
|
+
error = `EndTest failed: {tostring(endErr)}`,
|
|
360
|
+
}
|
|
361
|
+
postJson("/response", {
|
|
362
|
+
requestId = body.requestId,
|
|
363
|
+
response = response,
|
|
364
|
+
})
|
|
365
|
+
end
|
|
283
366
|
end
|
|
284
367
|
end
|
|
285
368
|
task.wait(0.15)
|
|
@@ -351,6 +434,7 @@ local BuildHandlers = TS.import(script, script.Parent, "handlers", "BuildHandler
|
|
|
351
434
|
local AssetHandlers = TS.import(script, script.Parent, "handlers", "AssetHandlers")
|
|
352
435
|
local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
|
|
353
436
|
local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
|
|
437
|
+
local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
|
|
354
438
|
local instanceId = HttpService:GenerateGUID(false)
|
|
355
439
|
local assignedRole
|
|
356
440
|
local function detectRole()
|
|
@@ -419,6 +503,7 @@ local routeMap = {
|
|
|
419
503
|
["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
|
|
420
504
|
["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
|
|
421
505
|
["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
|
|
506
|
+
["/api/get-runtime-logs"] = LogHandlers.getRuntimeLogs,
|
|
422
507
|
}
|
|
423
508
|
local function processRequest(request)
|
|
424
509
|
local endpoint = request.endpoint
|
|
@@ -460,6 +545,43 @@ local function getConnectionStatus(connIndex)
|
|
|
460
545
|
end
|
|
461
546
|
return "connecting"
|
|
462
547
|
end
|
|
548
|
+
-- Throttle for re-issuing /ready after the server reports knownInstance=false.
|
|
549
|
+
-- Without this, every poll during the brief window where the server has just
|
|
550
|
+
-- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
|
|
551
|
+
local lastReadyPostAt = 0
|
|
552
|
+
local function sendReady(conn)
|
|
553
|
+
local now = tick()
|
|
554
|
+
if now - lastReadyPostAt < 2 then
|
|
555
|
+
return nil
|
|
556
|
+
end
|
|
557
|
+
lastReadyPostAt = now
|
|
558
|
+
task.spawn(function()
|
|
559
|
+
local readyOk, readyResult = pcall(function()
|
|
560
|
+
return HttpService:RequestAsync({
|
|
561
|
+
Url = `{conn.serverUrl}/ready`,
|
|
562
|
+
Method = "POST",
|
|
563
|
+
Headers = {
|
|
564
|
+
["Content-Type"] = "application/json",
|
|
565
|
+
},
|
|
566
|
+
Body = HttpService:JSONEncode({
|
|
567
|
+
instanceId = instanceId,
|
|
568
|
+
role = detectRole(),
|
|
569
|
+
pluginReady = true,
|
|
570
|
+
timestamp = tick(),
|
|
571
|
+
}),
|
|
572
|
+
})
|
|
573
|
+
end)
|
|
574
|
+
if readyOk and readyResult.Success then
|
|
575
|
+
local parseOk, readyData = pcall(function()
|
|
576
|
+
return HttpService:JSONDecode(readyResult.Body)
|
|
577
|
+
end)
|
|
578
|
+
local _value = parseOk and readyData.assignedRole
|
|
579
|
+
if _value ~= "" and _value then
|
|
580
|
+
assignedRole = readyData.assignedRole
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end)
|
|
584
|
+
end
|
|
463
585
|
local function pollForRequests(connIndex)
|
|
464
586
|
local conn = State.getConnection(connIndex)
|
|
465
587
|
if not conn or not conn.isActive then
|
|
@@ -489,6 +611,14 @@ local function pollForRequests(connIndex)
|
|
|
489
611
|
local mcpConnected = data.mcpConnected == true
|
|
490
612
|
conn.lastHttpOk = true
|
|
491
613
|
conn.lastMcpOk = mcpConnected
|
|
614
|
+
-- Server tells us when its in-memory instances map doesn't have us
|
|
615
|
+
-- (e.g. after an MCP process restart). Re-issue /ready immediately so
|
|
616
|
+
-- target=server/client-N start routing again. The throttle inside
|
|
617
|
+
-- sendReady() prevents duplicate registrations while the server
|
|
618
|
+
-- catches up.
|
|
619
|
+
if data.knownInstance == false then
|
|
620
|
+
sendReady(conn)
|
|
621
|
+
end
|
|
492
622
|
if connIndex == State.getActiveTabIndex() then
|
|
493
623
|
local el = ui
|
|
494
624
|
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94)
|
|
@@ -644,42 +774,19 @@ local function activatePlugin(connIndex)
|
|
|
644
774
|
UI.updateUIState()
|
|
645
775
|
end
|
|
646
776
|
UI.updateTabDot(idx)
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
pollForRequests(idx)
|
|
655
|
-
end
|
|
656
|
-
end)
|
|
657
|
-
end
|
|
658
|
-
local readyOk, readyResult = pcall(function()
|
|
659
|
-
return HttpService:RequestAsync({
|
|
660
|
-
Url = `{conn.serverUrl}/ready`,
|
|
661
|
-
Method = "POST",
|
|
662
|
-
Headers = {
|
|
663
|
-
["Content-Type"] = "application/json",
|
|
664
|
-
},
|
|
665
|
-
Body = HttpService:JSONEncode({
|
|
666
|
-
instanceId = instanceId,
|
|
667
|
-
role = detectRole(),
|
|
668
|
-
pluginReady = true,
|
|
669
|
-
timestamp = tick(),
|
|
670
|
-
}),
|
|
671
|
-
})
|
|
672
|
-
end)
|
|
673
|
-
if readyOk and readyResult.Success then
|
|
674
|
-
local parseOk, readyData = pcall(function()
|
|
675
|
-
return HttpService:JSONDecode(readyResult.Body)
|
|
676
|
-
end)
|
|
677
|
-
local _value = parseOk and readyData.assignedRole
|
|
678
|
-
if _value ~= "" and _value then
|
|
679
|
-
assignedRole = readyData.assignedRole
|
|
777
|
+
if not conn.heartbeatConnection then
|
|
778
|
+
conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
|
|
779
|
+
local now = tick()
|
|
780
|
+
local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
|
|
781
|
+
if now - conn.lastPoll > currentInterval then
|
|
782
|
+
conn.lastPoll = now
|
|
783
|
+
pollForRequests(idx)
|
|
680
784
|
end
|
|
681
|
-
end
|
|
682
|
-
end
|
|
785
|
+
end)
|
|
786
|
+
end
|
|
787
|
+
-- Initial /ready; pollForRequests will also re-fire ready if the server
|
|
788
|
+
-- later reports knownInstance=false (process restart, etc).
|
|
789
|
+
sendReady(conn)
|
|
683
790
|
end
|
|
684
791
|
local function deactivatePlugin(connIndex)
|
|
685
792
|
local _condition = connIndex
|
|
@@ -796,7 +903,18 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
796
903
|
-- DataModel into the play DMs, so the scripts come along and run there.
|
|
797
904
|
-- TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
|
|
798
905
|
-- returns (test ended for any reason: stop_playtest, manual close, EndTest).
|
|
799
|
-
--
|
|
906
|
+
--
|
|
907
|
+
-- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
|
|
908
|
+
-- with Archivable=false (verified empirically in v2.9.0 testing - bridges
|
|
909
|
+
-- never reached the play DMs because we'd set them to false). We now keep
|
|
910
|
+
-- Archivable=true so the clone works, and rely on cleanupBridges() to
|
|
911
|
+
-- remove the scripts from the edit DM when the test ends. The only failure
|
|
912
|
+
-- mode is the user saving DURING an active playtest, which would persist
|
|
913
|
+
-- the bridges to the .rbxl - that's a no-op next session because
|
|
914
|
+
-- installBridges() always calls cleanupBridges() first to clear stale
|
|
915
|
+
-- instances. The RemoteFunction/BindableFunction that the bridge scripts
|
|
916
|
+
-- CREATE at runtime stay Archivable=false (they're runtime-only and should
|
|
917
|
+
-- never appear in a save).
|
|
800
918
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
801
919
|
local ServerScriptService = _services.ServerScriptService
|
|
802
920
|
local StarterPlayer = _services.StarterPlayer
|
|
@@ -944,7 +1062,9 @@ local function installBridges()
|
|
|
944
1062
|
local ok, err = pcall(function()
|
|
945
1063
|
local serverScript = Instance.new("Script")
|
|
946
1064
|
serverScript.Name = SERVER_SCRIPT_NAME
|
|
947
|
-
|
|
1065
|
+
-- Archivable=true so ExecutePlayModeAsync's deep-clone includes the
|
|
1066
|
+
-- script. cleanupBridges() removes it from the edit DM when the
|
|
1067
|
+
-- playtest ends.
|
|
948
1068
|
setSource(serverScript, SERVER_BRIDGE_SOURCE)
|
|
949
1069
|
serverScript.Parent = ServerScriptService
|
|
950
1070
|
local sps = getStarterPlayerScripts()
|
|
@@ -953,7 +1073,6 @@ local function installBridges()
|
|
|
953
1073
|
end
|
|
954
1074
|
local clientScript = Instance.new("LocalScript")
|
|
955
1075
|
clientScript.Name = CLIENT_SCRIPT_NAME
|
|
956
|
-
clientScript.Archivable = false
|
|
957
1076
|
setSource(clientScript, CLIENT_BRIDGE_SOURCE)
|
|
958
1077
|
clientScript.Parent = sps
|
|
959
1078
|
end)
|
|
@@ -2669,6 +2788,33 @@ return {
|
|
|
2669
2788
|
</Properties>
|
|
2670
2789
|
</Item>
|
|
2671
2790
|
<Item class="ModuleScript" referent="11">
|
|
2791
|
+
<Properties>
|
|
2792
|
+
<string name="Name">LogHandlers</string>
|
|
2793
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2794
|
+
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2795
|
+
local RuntimeLogBuffer = TS.import(script, script.Parent.Parent, "RuntimeLogBuffer")
|
|
2796
|
+
local function getRuntimeLogs(requestData)
|
|
2797
|
+
local since = requestData.since
|
|
2798
|
+
local tail = requestData.tail
|
|
2799
|
+
local filter = requestData.filter
|
|
2800
|
+
-- Plugin-side peer tag is generic ("edit"|"server"|"client"). The MCP-side
|
|
2801
|
+
-- aggregator overrides it with the specific instance role (e.g. "client-1")
|
|
2802
|
+
-- during fan-out for target=all, so this value is only authoritative for
|
|
2803
|
+
-- the single-peer query path.
|
|
2804
|
+
local peer = RuntimeLogBuffer.detectPeer()
|
|
2805
|
+
return RuntimeLogBuffer.query({
|
|
2806
|
+
since = since,
|
|
2807
|
+
tail = tail,
|
|
2808
|
+
filter = filter,
|
|
2809
|
+
}, peer)
|
|
2810
|
+
end
|
|
2811
|
+
return {
|
|
2812
|
+
getRuntimeLogs = getRuntimeLogs,
|
|
2813
|
+
}
|
|
2814
|
+
]]></string>
|
|
2815
|
+
</Properties>
|
|
2816
|
+
</Item>
|
|
2817
|
+
<Item class="ModuleScript" referent="12">
|
|
2672
2818
|
<Properties>
|
|
2673
2819
|
<string name="Name">MetadataHandlers</string>
|
|
2674
2820
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3127,13 +3273,67 @@ local function executeLuau(requestData)
|
|
|
3127
3273
|
table.insert(output, _arg0)
|
|
3128
3274
|
oldWarn(unpack(args))
|
|
3129
3275
|
end
|
|
3276
|
+
-- Try loadstring first (preserves print/warn interception). When
|
|
3277
|
+
-- ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
|
|
3278
|
+
-- peer where the engine respects that gate (notably the play-server DM
|
|
3279
|
+
-- in some Studio configurations), loadstring either returns nil with a
|
|
3280
|
+
-- "loadstring() is not available" message OR throws that same message
|
|
3281
|
+
-- directly. Both paths must trigger the ModuleScript + require
|
|
3282
|
+
-- fallback. The fallback can't intercept print/warn since the
|
|
3283
|
+
-- ModuleScript runs in its own environment, so the output array stays
|
|
3284
|
+
-- empty in that branch - the playtest log buffer already captures
|
|
3285
|
+
-- prints separately via LogService.MessageOut.
|
|
3286
|
+
local runViaModuleScript = function()
|
|
3287
|
+
local m = Instance.new("ModuleScript")
|
|
3288
|
+
m.Name = "__MCPExecLuauPayload"
|
|
3289
|
+
-- Wrap user code in an IIFE so require() always gets exactly one
|
|
3290
|
+
-- return value. Without this, code like `print("x")` errors with
|
|
3291
|
+
-- "Module code did not return exactly one value" because top-level
|
|
3292
|
+
-- ModuleScripts must return exactly one value.
|
|
3293
|
+
--
|
|
3294
|
+
-- The DOUBLE parens around the call are load-bearing: in Luau,
|
|
3295
|
+
-- `return f()` propagates whatever multi-value tuple f returns,
|
|
3296
|
+
-- including zero values. Outer parens adjust the call to exactly
|
|
3297
|
+
-- one value (the first, or nil). So `return ((f)())` always
|
|
3298
|
+
-- returns exactly one value, regardless of what f does.
|
|
3299
|
+
local wrapped = `return ((function()\n{code}\nend)())`
|
|
3300
|
+
local okSet, setErr = pcall(function()
|
|
3301
|
+
m.Source = wrapped
|
|
3302
|
+
end)
|
|
3303
|
+
if not okSet then
|
|
3304
|
+
m:Destroy()
|
|
3305
|
+
error(`ModuleScript Source set failed: {tostring(setErr)}`)
|
|
3306
|
+
end
|
|
3307
|
+
m.Parent = game:GetService("Workspace")
|
|
3308
|
+
local okReq, reqResult = pcall(function()
|
|
3309
|
+
return require(m)
|
|
3310
|
+
end)
|
|
3311
|
+
m:Destroy()
|
|
3312
|
+
if not okReq then
|
|
3313
|
+
error(tostring(reqResult))
|
|
3314
|
+
end
|
|
3315
|
+
return reqResult
|
|
3316
|
+
end
|
|
3317
|
+
local isLoadstringUnavailable = function(err)
|
|
3318
|
+
local errStr = tostring(err)
|
|
3319
|
+
local matchStart = string.find(errStr, "not available", 1, true)
|
|
3320
|
+
return matchStart ~= nil
|
|
3321
|
+
end
|
|
3130
3322
|
local success, result = pcall(function()
|
|
3131
3323
|
local fn, compileError = loadstring(code)
|
|
3132
3324
|
if not fn then
|
|
3325
|
+
if isLoadstringUnavailable(compileError) then
|
|
3326
|
+
return runViaModuleScript()
|
|
3327
|
+
end
|
|
3133
3328
|
error(`Compile error: {compileError}`)
|
|
3134
3329
|
end
|
|
3135
3330
|
return fn()
|
|
3136
3331
|
end)
|
|
3332
|
+
-- loadstring throws (not returns nil) in some plugin contexts when
|
|
3333
|
+
-- LoadStringEnabled=false. Catch that here as a second-chance fallback.
|
|
3334
|
+
if not success and isLoadstringUnavailable(result) then
|
|
3335
|
+
success, result = pcall(runViaModuleScript)
|
|
3336
|
+
end
|
|
3137
3337
|
env.print = oldPrint
|
|
3138
3338
|
env.warn = oldWarn
|
|
3139
3339
|
if success then
|
|
@@ -3251,7 +3451,7 @@ return {
|
|
|
3251
3451
|
]]></string>
|
|
3252
3452
|
</Properties>
|
|
3253
3453
|
</Item>
|
|
3254
|
-
<Item class="ModuleScript" referent="
|
|
3454
|
+
<Item class="ModuleScript" referent="13">
|
|
3255
3455
|
<Properties>
|
|
3256
3456
|
<string name="Name">PropertyHandlers</string>
|
|
3257
3457
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -3503,7 +3703,7 @@ return {
|
|
|
3503
3703
|
]]></string>
|
|
3504
3704
|
</Properties>
|
|
3505
3705
|
</Item>
|
|
3506
|
-
<Item class="ModuleScript" referent="
|
|
3706
|
+
<Item class="ModuleScript" referent="14">
|
|
3507
3707
|
<Properties>
|
|
3508
3708
|
<string name="Name">QueryHandlers</string>
|
|
3509
3709
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -4530,7 +4730,7 @@ return {
|
|
|
4530
4730
|
]]></string>
|
|
4531
4731
|
</Properties>
|
|
4532
4732
|
</Item>
|
|
4533
|
-
<Item class="ModuleScript" referent="
|
|
4733
|
+
<Item class="ModuleScript" referent="15">
|
|
4534
4734
|
<Properties>
|
|
4535
4735
|
<string name="Name">ScriptHandlers</string>
|
|
4536
4736
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5226,7 +5426,7 @@ return {
|
|
|
5226
5426
|
]]></string>
|
|
5227
5427
|
</Properties>
|
|
5228
5428
|
</Item>
|
|
5229
|
-
<Item class="ModuleScript" referent="
|
|
5429
|
+
<Item class="ModuleScript" referent="16">
|
|
5230
5430
|
<Properties>
|
|
5231
5431
|
<string name="Name">TestHandlers</string>
|
|
5232
5432
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5545,7 +5745,7 @@ return {
|
|
|
5545
5745
|
</Properties>
|
|
5546
5746
|
</Item>
|
|
5547
5747
|
</Item>
|
|
5548
|
-
<Item class="ModuleScript" referent="
|
|
5748
|
+
<Item class="ModuleScript" referent="17">
|
|
5549
5749
|
<Properties>
|
|
5550
5750
|
<string name="Name">Recording</string>
|
|
5551
5751
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -5575,11 +5775,192 @@ return {
|
|
|
5575
5775
|
]]></string>
|
|
5576
5776
|
</Properties>
|
|
5577
5777
|
</Item>
|
|
5578
|
-
<Item class="ModuleScript" referent="
|
|
5778
|
+
<Item class="ModuleScript" referent="18">
|
|
5779
|
+
<Properties>
|
|
5780
|
+
<string name="Name">RuntimeLogBuffer</string>
|
|
5781
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
5782
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
5783
|
+
-- Per-peer in-memory ring buffer for LogService.MessageOut events.
|
|
5784
|
+
-- Powers the get_runtime_logs MCP tool. Replaces the out-of-tree LogBuffer
|
|
5785
|
+
-- primitives + StringValue approach from chrrxs/roblox-mcp-primitives.
|
|
5786
|
+
--
|
|
5787
|
+
-- Each peer's plugin attaches a MessageOut listener at plugin load (edit DM,
|
|
5788
|
+
-- play-server DM, play-client DM all run their own copy of this module).
|
|
5789
|
+
-- Captured entries live in plugin module-state; nothing is parented to the
|
|
5790
|
+
-- DataModel. The buffer is bounded by a message-byte budget; oldest entries
|
|
5791
|
+
-- drop when over budget.
|
|
5792
|
+
--
|
|
5793
|
+
-- Peer-tag caveat: returned entries reflect which peer's plugin CAPTURED the
|
|
5794
|
+
-- entry, NOT which peer's script originated the print. LogService reflects
|
|
5795
|
+
-- prints across peers in Studio Play (a server print ends up in both the
|
|
5796
|
+
-- server and client LogService:GetLogHistory()) and origin is empirically
|
|
5797
|
+
-- undetectable from inside MessageOut. The MCP-side aggregator handles
|
|
5798
|
+
-- cross-peer dedup via a 2s timestamp window.
|
|
5799
|
+
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
5800
|
+
local LogService = _services.LogService
|
|
5801
|
+
local RunService = _services.RunService
|
|
5802
|
+
local MAX_BYTES = 64 * 1024
|
|
5803
|
+
local HARD_ENTRY_CAP = 50_000
|
|
5804
|
+
local entries = {}
|
|
5805
|
+
local totalBytes = 0
|
|
5806
|
+
local totalDropped = 0
|
|
5807
|
+
local nextSeq = 1
|
|
5808
|
+
local installed = false
|
|
5809
|
+
local function levelTag(t)
|
|
5810
|
+
if t == Enum.MessageType.MessageWarning then
|
|
5811
|
+
return "WARN"
|
|
5812
|
+
end
|
|
5813
|
+
if t == Enum.MessageType.MessageError then
|
|
5814
|
+
return "ERR"
|
|
5815
|
+
end
|
|
5816
|
+
if t == Enum.MessageType.MessageInfo then
|
|
5817
|
+
return "INFO"
|
|
5818
|
+
end
|
|
5819
|
+
return "OUT"
|
|
5820
|
+
end
|
|
5821
|
+
local function nowSec()
|
|
5822
|
+
return DateTime.now().UnixTimestampMillis / 1000
|
|
5823
|
+
end
|
|
5824
|
+
local function dropOldestUntilFits(incomingBytes)
|
|
5825
|
+
while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
|
|
5826
|
+
local dropped = table.remove(entries, 1)
|
|
5827
|
+
totalBytes -= #dropped.message
|
|
5828
|
+
totalDropped += 1
|
|
5829
|
+
end
|
|
5830
|
+
end
|
|
5831
|
+
local function install()
|
|
5832
|
+
if installed then
|
|
5833
|
+
return nil
|
|
5834
|
+
end
|
|
5835
|
+
if not RunService:IsStudio() then
|
|
5836
|
+
return nil
|
|
5837
|
+
end
|
|
5838
|
+
installed = true
|
|
5839
|
+
LogService.MessageOut:Connect(function(msg, t)
|
|
5840
|
+
local bytes = #msg
|
|
5841
|
+
dropOldestUntilFits(bytes)
|
|
5842
|
+
local _arg0 = {
|
|
5843
|
+
seq = nextSeq,
|
|
5844
|
+
ts = nowSec(),
|
|
5845
|
+
level = levelTag(t),
|
|
5846
|
+
message = msg,
|
|
5847
|
+
}
|
|
5848
|
+
table.insert(entries, _arg0)
|
|
5849
|
+
nextSeq += 1
|
|
5850
|
+
totalBytes += bytes
|
|
5851
|
+
end)
|
|
5852
|
+
end
|
|
5853
|
+
local function detectPeer()
|
|
5854
|
+
if not RunService:IsRunning() then
|
|
5855
|
+
return "edit"
|
|
5856
|
+
end
|
|
5857
|
+
if RunService:IsServer() then
|
|
5858
|
+
return "server"
|
|
5859
|
+
end
|
|
5860
|
+
return "client"
|
|
5861
|
+
end
|
|
5862
|
+
local function query(opts, peer)
|
|
5863
|
+
local _result
|
|
5864
|
+
if opts.since ~= nil then
|
|
5865
|
+
-- ▼ ReadonlyArray.filter ▼
|
|
5866
|
+
local _newValue = {}
|
|
5867
|
+
local _callback = function(e)
|
|
5868
|
+
return e.seq > (opts.since)
|
|
5869
|
+
end
|
|
5870
|
+
local _length = 0
|
|
5871
|
+
for _k, _v in entries do
|
|
5872
|
+
if _callback(_v, _k - 1, entries) == true then
|
|
5873
|
+
_length += 1
|
|
5874
|
+
_newValue[_length] = _v
|
|
5875
|
+
end
|
|
5876
|
+
end
|
|
5877
|
+
-- ▲ ReadonlyArray.filter ▲
|
|
5878
|
+
_result = _newValue
|
|
5879
|
+
else
|
|
5880
|
+
local _array = {}
|
|
5881
|
+
local _length = #_array
|
|
5882
|
+
table.move(entries, 1, #entries, _length + 1, _array)
|
|
5883
|
+
_result = _array
|
|
5884
|
+
end
|
|
5885
|
+
local result = _result
|
|
5886
|
+
if opts.filter ~= nil then
|
|
5887
|
+
-- Plain substring search (4th arg = true). Pattern matching here was
|
|
5888
|
+
-- surprising in practice - Lua magic chars in messages would silently
|
|
5889
|
+
-- not match (e.g. filter="MARK-EDIT" against "MARK-EDIT-001" fails
|
|
5890
|
+
-- because '-' means "0+" in Lua patterns). Substring search matches
|
|
5891
|
+
-- most users' mental model of "filter messages containing this text".
|
|
5892
|
+
local needle = opts.filter
|
|
5893
|
+
-- ▼ ReadonlyArray.filter ▼
|
|
5894
|
+
local _newValue = {}
|
|
5895
|
+
local _callback = function(e)
|
|
5896
|
+
local start = string.find(e.message, needle, 1, true)
|
|
5897
|
+
return start ~= nil
|
|
5898
|
+
end
|
|
5899
|
+
local _length = 0
|
|
5900
|
+
for _k, _v in result do
|
|
5901
|
+
if _callback(_v, _k - 1, result) == true then
|
|
5902
|
+
_length += 1
|
|
5903
|
+
_newValue[_length] = _v
|
|
5904
|
+
end
|
|
5905
|
+
end
|
|
5906
|
+
-- ▲ ReadonlyArray.filter ▲
|
|
5907
|
+
result = _newValue
|
|
5908
|
+
end
|
|
5909
|
+
if opts.tail ~= nil and #result > opts.tail then
|
|
5910
|
+
-- roblox-ts arrays don't expose .slice; manual tail copy.
|
|
5911
|
+
local tailed = {}
|
|
5912
|
+
local start = #result - opts.tail
|
|
5913
|
+
do
|
|
5914
|
+
local i = start
|
|
5915
|
+
local _shouldIncrement = false
|
|
5916
|
+
while true do
|
|
5917
|
+
if _shouldIncrement then
|
|
5918
|
+
i += 1
|
|
5919
|
+
else
|
|
5920
|
+
_shouldIncrement = true
|
|
5921
|
+
end
|
|
5922
|
+
if not (i < #result) then
|
|
5923
|
+
break
|
|
5924
|
+
end
|
|
5925
|
+
local _arg0 = result[i + 1]
|
|
5926
|
+
table.insert(tailed, _arg0)
|
|
5927
|
+
end
|
|
5928
|
+
end
|
|
5929
|
+
result = tailed
|
|
5930
|
+
end
|
|
5931
|
+
local last = if #entries > 0 then entries[#entries] else nil
|
|
5932
|
+
local _object = {
|
|
5933
|
+
peer = peer,
|
|
5934
|
+
entries = result,
|
|
5935
|
+
totalDropped = totalDropped,
|
|
5936
|
+
}
|
|
5937
|
+
local _left = "nextSince"
|
|
5938
|
+
local _result_1
|
|
5939
|
+
if last then
|
|
5940
|
+
_result_1 = last.seq
|
|
5941
|
+
else
|
|
5942
|
+
local _condition = opts.since
|
|
5943
|
+
if _condition == nil then
|
|
5944
|
+
_condition = 0
|
|
5945
|
+
end
|
|
5946
|
+
_result_1 = _condition
|
|
5947
|
+
end
|
|
5948
|
+
_object[_left] = _result_1
|
|
5949
|
+
return _object
|
|
5950
|
+
end
|
|
5951
|
+
return {
|
|
5952
|
+
install = install,
|
|
5953
|
+
detectPeer = detectPeer,
|
|
5954
|
+
query = query,
|
|
5955
|
+
}
|
|
5956
|
+
]]></string>
|
|
5957
|
+
</Properties>
|
|
5958
|
+
</Item>
|
|
5959
|
+
<Item class="ModuleScript" referent="19">
|
|
5579
5960
|
<Properties>
|
|
5580
5961
|
<string name="Name">State</string>
|
|
5581
5962
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
5582
|
-
local CURRENT_VERSION = "2.
|
|
5963
|
+
local CURRENT_VERSION = "2.10.0"
|
|
5583
5964
|
local MAX_CONNECTIONS = 5
|
|
5584
5965
|
local BASE_PORT = 58741
|
|
5585
5966
|
local activeTabIndex = 0
|
|
@@ -5671,7 +6052,7 @@ return {
|
|
|
5671
6052
|
]]></string>
|
|
5672
6053
|
</Properties>
|
|
5673
6054
|
</Item>
|
|
5674
|
-
<Item class="ModuleScript" referent="
|
|
6055
|
+
<Item class="ModuleScript" referent="20">
|
|
5675
6056
|
<Properties>
|
|
5676
6057
|
<string name="Name">UI</string>
|
|
5677
6058
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6391,7 +6772,7 @@ return {
|
|
|
6391
6772
|
]]></string>
|
|
6392
6773
|
</Properties>
|
|
6393
6774
|
</Item>
|
|
6394
|
-
<Item class="ModuleScript" referent="
|
|
6775
|
+
<Item class="ModuleScript" referent="21">
|
|
6395
6776
|
<Properties>
|
|
6396
6777
|
<string name="Name">Utils</string>
|
|
6397
6778
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6921,11 +7302,11 @@ return {
|
|
|
6921
7302
|
</Properties>
|
|
6922
7303
|
</Item>
|
|
6923
7304
|
</Item>
|
|
6924
|
-
<Item class="Folder" referent="
|
|
7305
|
+
<Item class="Folder" referent="25">
|
|
6925
7306
|
<Properties>
|
|
6926
7307
|
<string name="Name">include</string>
|
|
6927
7308
|
</Properties>
|
|
6928
|
-
<Item class="ModuleScript" referent="
|
|
7309
|
+
<Item class="ModuleScript" referent="22">
|
|
6929
7310
|
<Properties>
|
|
6930
7311
|
<string name="Name">Promise</string>
|
|
6931
7312
|
<string name="Source"><![CDATA[--[[
|
|
@@ -8999,7 +9380,7 @@ return Promise
|
|
|
8999
9380
|
]]></string>
|
|
9000
9381
|
</Properties>
|
|
9001
9382
|
</Item>
|
|
9002
|
-
<Item class="ModuleScript" referent="
|
|
9383
|
+
<Item class="ModuleScript" referent="23">
|
|
9003
9384
|
<Properties>
|
|
9004
9385
|
<string name="Name">RuntimeLib</string>
|
|
9005
9386
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -9266,15 +9647,15 @@ return TS
|
|
|
9266
9647
|
</Properties>
|
|
9267
9648
|
</Item>
|
|
9268
9649
|
</Item>
|
|
9269
|
-
<Item class="Folder" referent="
|
|
9650
|
+
<Item class="Folder" referent="26">
|
|
9270
9651
|
<Properties>
|
|
9271
9652
|
<string name="Name">node_modules</string>
|
|
9272
9653
|
</Properties>
|
|
9273
|
-
<Item class="Folder" referent="
|
|
9654
|
+
<Item class="Folder" referent="27">
|
|
9274
9655
|
<Properties>
|
|
9275
9656
|
<string name="Name">@rbxts</string>
|
|
9276
9657
|
</Properties>
|
|
9277
|
-
<Item class="ModuleScript" referent="
|
|
9658
|
+
<Item class="ModuleScript" referent="24">
|
|
9278
9659
|
<Properties>
|
|
9279
9660
|
<string name="Name">services</string>
|
|
9280
9661
|
<string name="Source"><![CDATA[return setmetatable({}, {
|