@chrrxs/robloxstudio-mcp 2.11.4 → 2.12.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 +1003 -404
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +597 -218
- package/studio-plugin/src/modules/ClientBroker.ts +69 -35
- package/studio-plugin/src/modules/Communication.ts +101 -5
- package/studio-plugin/src/modules/LuauExec.ts +305 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +67 -31
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +7 -146
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +20 -2
- package/studio-plugin/src/types/index.d.ts +5 -2
|
@@ -94,8 +94,50 @@ local HttpService = _services.HttpService
|
|
|
94
94
|
local Players = _services.Players
|
|
95
95
|
local ReplicatedStorage = _services.ReplicatedStorage
|
|
96
96
|
local RunService = _services.RunService
|
|
97
|
+
local ServerStorage = _services.ServerStorage
|
|
97
98
|
local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
|
|
98
99
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
100
|
+
local LuauExec = TS.import(script, script.Parent, "LuauExec")
|
|
101
|
+
-- Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
102
|
+
-- client broker runs in the play-server DM where it can't easily import from
|
|
103
|
+
-- the edit-side module, and the place identifier must match what the edit-DM
|
|
104
|
+
-- plugin reports. Both use the same algorithm against the shared DataModel.
|
|
105
|
+
local function computeInstanceId()
|
|
106
|
+
if game.PlaceId ~= 0 then
|
|
107
|
+
return `place:{tostring(game.PlaceId)}`
|
|
108
|
+
end
|
|
109
|
+
local existing = ServerStorage:GetAttribute("__MCPPlaceId")
|
|
110
|
+
if type(existing) == "string" and existing ~= "" then
|
|
111
|
+
return `anon:{existing}`
|
|
112
|
+
end
|
|
113
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
114
|
+
pcall(function()
|
|
115
|
+
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
116
|
+
end)
|
|
117
|
+
return `anon:{fresh}`
|
|
118
|
+
end
|
|
119
|
+
local cachedPlaceName
|
|
120
|
+
local function resolvePlaceName()
|
|
121
|
+
if cachedPlaceName ~= nil then
|
|
122
|
+
return cachedPlaceName
|
|
123
|
+
end
|
|
124
|
+
if game.PlaceId == 0 then
|
|
125
|
+
cachedPlaceName = game.Name
|
|
126
|
+
return cachedPlaceName
|
|
127
|
+
end
|
|
128
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
129
|
+
local ok, info = pcall(function()
|
|
130
|
+
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
131
|
+
end)
|
|
132
|
+
if ok and info ~= nil then
|
|
133
|
+
local name = info.Name
|
|
134
|
+
if type(name) == "string" and name ~= "" then
|
|
135
|
+
cachedPlaceName = name
|
|
136
|
+
return cachedPlaceName
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
return game.Name
|
|
140
|
+
end
|
|
99
141
|
-- The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
100
142
|
-- HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
101
143
|
-- HttpEnabled reads as false there regardless of identity. So the server peer
|
|
@@ -136,8 +178,13 @@ local function reRegisterProxy(proxyId, role)
|
|
|
136
178
|
lastReadyByProxy[_proxyId_1] = now
|
|
137
179
|
pcall(function()
|
|
138
180
|
return postJson("/ready", {
|
|
139
|
-
|
|
181
|
+
pluginSessionId = proxyId,
|
|
182
|
+
instanceId = computeInstanceId(),
|
|
140
183
|
role = role,
|
|
184
|
+
placeId = game.PlaceId,
|
|
185
|
+
placeName = resolvePlaceName(),
|
|
186
|
+
dataModelName = game.Name,
|
|
187
|
+
isRunning = RunService:IsRunning(),
|
|
141
188
|
})
|
|
142
189
|
end)
|
|
143
190
|
end
|
|
@@ -170,34 +217,11 @@ local function handleExecuteLuau(data)
|
|
|
170
217
|
error = "code is required",
|
|
171
218
|
}
|
|
172
219
|
end
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if not okSet then
|
|
179
|
-
m:Destroy()
|
|
180
|
-
return {
|
|
181
|
-
success = false,
|
|
182
|
-
error = `Source set failed: {tostring(setErr)}`,
|
|
183
|
-
}
|
|
184
|
-
end
|
|
185
|
-
m.Parent = game.Workspace
|
|
186
|
-
local okReq, result = pcall(function()
|
|
187
|
-
return require(m)
|
|
188
|
-
end)
|
|
189
|
-
m:Destroy()
|
|
190
|
-
if okReq then
|
|
191
|
-
return {
|
|
192
|
-
success = true,
|
|
193
|
-
returnValue = if result ~= nil then tostring(result) else nil,
|
|
194
|
-
message = "Code executed successfully",
|
|
195
|
-
}
|
|
196
|
-
end
|
|
197
|
-
return {
|
|
198
|
-
success = false,
|
|
199
|
-
error = tostring(result),
|
|
200
|
-
}
|
|
220
|
+
-- Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
|
|
221
|
+
-- wrapper (so `print("hi")` with no return doesn't fail the
|
|
222
|
+
-- ModuleScript's "must return one value" rule) and JSON-encodes table
|
|
223
|
+
-- returns instead of yielding "table: 0xaddr".
|
|
224
|
+
return LuauExec.execute(code)
|
|
201
225
|
end
|
|
202
226
|
local function handleGetRuntimeLogs(data)
|
|
203
227
|
local d = data or {}
|
|
@@ -251,7 +275,7 @@ local function pollProxy(proxyId, player, rf)
|
|
|
251
275
|
end
|
|
252
276
|
local ok, res = pcall(function()
|
|
253
277
|
return HttpService:RequestAsync({
|
|
254
|
-
Url = `{MCP_URL}/poll?
|
|
278
|
+
Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
|
|
255
279
|
Method = "GET",
|
|
256
280
|
Headers = {
|
|
257
281
|
["Content-Type"] = "application/json",
|
|
@@ -315,8 +339,13 @@ local function registerProxy(player, rf)
|
|
|
315
339
|
end
|
|
316
340
|
local proxyId = HttpService:GenerateGUID(false)
|
|
317
341
|
local ok, res = postJson("/ready", {
|
|
318
|
-
|
|
342
|
+
pluginSessionId = proxyId,
|
|
343
|
+
instanceId = computeInstanceId(),
|
|
319
344
|
role = "client",
|
|
345
|
+
placeId = game.PlaceId,
|
|
346
|
+
placeName = resolvePlaceName(),
|
|
347
|
+
dataModelName = game.Name,
|
|
348
|
+
isRunning = RunService:IsRunning(),
|
|
320
349
|
})
|
|
321
350
|
if not ok or not res or not res.Success then
|
|
322
351
|
warn(`[MCPFork] proxy register failed for {player.Name}`)
|
|
@@ -330,7 +359,7 @@ local function registerProxy(player, rf)
|
|
|
330
359
|
local assigned = _condition
|
|
331
360
|
local _player_1 = player
|
|
332
361
|
local _arg1 = {
|
|
333
|
-
|
|
362
|
+
pluginSessionId = proxyId,
|
|
334
363
|
role = assigned,
|
|
335
364
|
}
|
|
336
365
|
proxyByPlayer[_player_1] = _arg1
|
|
@@ -361,14 +390,14 @@ local function setupServerBroker()
|
|
|
361
390
|
local _p_1 = p
|
|
362
391
|
proxyByPlayer[_p_1] = nil
|
|
363
392
|
postJson("/disconnect", {
|
|
364
|
-
|
|
393
|
+
pluginSessionId = entry.pluginSessionId,
|
|
365
394
|
})
|
|
366
395
|
end
|
|
367
396
|
end)
|
|
368
397
|
game:BindToClose(function()
|
|
369
398
|
for _, entry in proxyByPlayer do
|
|
370
399
|
postJson("/disconnect", {
|
|
371
|
-
|
|
400
|
+
pluginSessionId = entry.pluginSessionId,
|
|
372
401
|
})
|
|
373
402
|
end
|
|
374
403
|
table.clear(proxyByPlayer)
|
|
@@ -391,6 +420,7 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
|
391
420
|
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
392
421
|
local HttpService = _services.HttpService
|
|
393
422
|
local RunService = _services.RunService
|
|
423
|
+
local ServerStorage = _services.ServerStorage
|
|
394
424
|
local State = TS.import(script, script.Parent, "State")
|
|
395
425
|
local Utils = TS.import(script, script.Parent, "Utils")
|
|
396
426
|
local UI = TS.import(script, script.Parent, "UI")
|
|
@@ -407,8 +437,61 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
|
|
|
407
437
|
local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
|
|
408
438
|
local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
|
|
409
439
|
local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
|
|
410
|
-
|
|
440
|
+
-- Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
441
|
+
-- can tell our polls apart from any other plugin's polls. Not user-facing —
|
|
442
|
+
-- MCP tools and the LLM operate on instanceId (the place identifier).
|
|
443
|
+
local pluginSessionId = HttpService:GenerateGUID(false)
|
|
444
|
+
-- Place-level identifier shared by every plugin running in DataModels of
|
|
445
|
+
-- the same place file (edit DM + playtest server DM + playtest clients).
|
|
446
|
+
-- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
|
|
447
|
+
-- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
|
|
448
|
+
-- and travels with the .rbxl.
|
|
449
|
+
local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
|
|
450
|
+
local function computeInstanceId()
|
|
451
|
+
if game.PlaceId ~= 0 then
|
|
452
|
+
return `place:{tostring(game.PlaceId)}`
|
|
453
|
+
end
|
|
454
|
+
local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
|
|
455
|
+
if type(existing) == "string" and existing ~= "" then
|
|
456
|
+
return `anon:{existing}`
|
|
457
|
+
end
|
|
458
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
459
|
+
pcall(function()
|
|
460
|
+
return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
|
|
461
|
+
end)
|
|
462
|
+
return `anon:{fresh}`
|
|
463
|
+
end
|
|
464
|
+
local instanceId = computeInstanceId()
|
|
411
465
|
local assignedRole
|
|
466
|
+
local duplicateInstanceRole = false
|
|
467
|
+
-- Cache the published place name from MarketplaceService:GetProductInfo so
|
|
468
|
+
-- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
469
|
+
-- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
470
|
+
-- once per plugin load; the published name doesn't change mid-session.
|
|
471
|
+
local cachedPlaceName
|
|
472
|
+
local function resolvePlaceName()
|
|
473
|
+
if cachedPlaceName ~= nil then
|
|
474
|
+
return cachedPlaceName
|
|
475
|
+
end
|
|
476
|
+
if game.PlaceId == 0 then
|
|
477
|
+
cachedPlaceName = game.Name
|
|
478
|
+
return cachedPlaceName
|
|
479
|
+
end
|
|
480
|
+
local MarketplaceService = game:GetService("MarketplaceService")
|
|
481
|
+
local ok, info = pcall(function()
|
|
482
|
+
return MarketplaceService:GetProductInfo(game.PlaceId)
|
|
483
|
+
end)
|
|
484
|
+
if ok and info ~= nil then
|
|
485
|
+
local name = info.Name
|
|
486
|
+
if type(name) == "string" and name ~= "" then
|
|
487
|
+
cachedPlaceName = name
|
|
488
|
+
return cachedPlaceName
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
-- Don't cache failures — could be transient (offline, rate-limited).
|
|
492
|
+
-- Next /ready will retry. Return game.Name as fallback.
|
|
493
|
+
return game.Name
|
|
494
|
+
end
|
|
412
495
|
local function detectRole()
|
|
413
496
|
if not RunService:IsRunning() then
|
|
414
497
|
return "edit"
|
|
@@ -524,7 +607,33 @@ end
|
|
|
524
607
|
-- Without this, every poll during the brief window where the server has just
|
|
525
608
|
-- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
|
|
526
609
|
local lastReadyPostAt = 0
|
|
527
|
-
|
|
610
|
+
-- game.Name is sometimes "Place1" at plugin-load time and only settles to
|
|
611
|
+
-- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
|
|
612
|
+
-- after Studio finishes wiring things up. Re-fire /ready when it changes so
|
|
613
|
+
-- get_connected_instances doesn't show a stale dataModelName forever. Set
|
|
614
|
+
-- up once per plugin load — the connection passed in is whichever was
|
|
615
|
+
-- active when activatePlugin was first called.
|
|
616
|
+
local nameChangeConn
|
|
617
|
+
local sendReady
|
|
618
|
+
local function ensureNameChangeWatcher(conn)
|
|
619
|
+
if nameChangeConn then
|
|
620
|
+
return nil
|
|
621
|
+
end
|
|
622
|
+
local okSig, signal = pcall(function()
|
|
623
|
+
return game:GetPropertyChangedSignal("Name")
|
|
624
|
+
end)
|
|
625
|
+
if not okSig or not signal then
|
|
626
|
+
return nil
|
|
627
|
+
end
|
|
628
|
+
nameChangeConn = signal:Connect(function()
|
|
629
|
+
-- sendReady has its own 2s throttle, so rapid burst changes coalesce.
|
|
630
|
+
sendReady(conn)
|
|
631
|
+
end)
|
|
632
|
+
end
|
|
633
|
+
function sendReady(conn)
|
|
634
|
+
if duplicateInstanceRole then
|
|
635
|
+
return nil
|
|
636
|
+
end
|
|
528
637
|
local now = tick()
|
|
529
638
|
if now - lastReadyPostAt < 2 then
|
|
530
639
|
return nil
|
|
@@ -539,14 +648,36 @@ local function sendReady(conn)
|
|
|
539
648
|
["Content-Type"] = "application/json",
|
|
540
649
|
},
|
|
541
650
|
Body = HttpService:JSONEncode({
|
|
651
|
+
pluginSessionId = pluginSessionId,
|
|
542
652
|
instanceId = instanceId,
|
|
543
653
|
role = detectRole(),
|
|
654
|
+
placeId = game.PlaceId,
|
|
655
|
+
placeName = resolvePlaceName(),
|
|
656
|
+
dataModelName = game.Name,
|
|
657
|
+
isRunning = RunService:IsRunning(),
|
|
544
658
|
pluginReady = true,
|
|
545
659
|
timestamp = tick(),
|
|
546
660
|
}),
|
|
547
661
|
})
|
|
548
662
|
end)
|
|
549
|
-
if readyOk
|
|
663
|
+
if not readyOk then
|
|
664
|
+
return nil
|
|
665
|
+
end
|
|
666
|
+
-- 409 = duplicate_instance_role. Surface in UI and stop polling.
|
|
667
|
+
if readyResult.StatusCode == 409 then
|
|
668
|
+
duplicateInstanceRole = true
|
|
669
|
+
conn.isActive = false
|
|
670
|
+
local ui = UI.getElements()
|
|
671
|
+
if State.getActiveTabIndex() == 0 then
|
|
672
|
+
ui.statusLabel.Text = "Duplicate instance"
|
|
673
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
674
|
+
ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
|
|
675
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
|
|
676
|
+
end
|
|
677
|
+
warn(`[MCPPlugin] Another Studio is already connected as ({instanceId}, {detectRole()}). Close the other Studio window or this one.`)
|
|
678
|
+
return nil
|
|
679
|
+
end
|
|
680
|
+
if readyResult.Success then
|
|
550
681
|
local parseOk, readyData = pcall(function()
|
|
551
682
|
return HttpService:JSONDecode(readyResult.Body)
|
|
552
683
|
end)
|
|
@@ -568,7 +699,7 @@ local function pollForRequests(connIndex)
|
|
|
568
699
|
conn.isPolling = true
|
|
569
700
|
local success, result = pcall(function()
|
|
570
701
|
return HttpService:RequestAsync({
|
|
571
|
-
Url = `{conn.serverUrl}/poll?
|
|
702
|
+
Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
|
|
572
703
|
Method = "GET",
|
|
573
704
|
Headers = {
|
|
574
705
|
["Content-Type"] = "application/json",
|
|
@@ -764,6 +895,9 @@ local function activatePlugin(connIndex)
|
|
|
764
895
|
-- Initial /ready; pollForRequests will also re-fire ready if the server
|
|
765
896
|
-- later reports knownInstance=false (process restart, etc).
|
|
766
897
|
sendReady(conn)
|
|
898
|
+
-- Watch for game.Name updates so a stale "Place1" captured at first
|
|
899
|
+
-- /ready gets refreshed once Studio settles on the real DM name.
|
|
900
|
+
ensureNameChangeWatcher(conn)
|
|
767
901
|
end
|
|
768
902
|
local function deactivatePlugin(connIndex)
|
|
769
903
|
local _condition = connIndex
|
|
@@ -789,7 +923,7 @@ local function deactivatePlugin(connIndex)
|
|
|
789
923
|
["Content-Type"] = "application/json",
|
|
790
924
|
},
|
|
791
925
|
Body = HttpService:JSONEncode({
|
|
792
|
-
|
|
926
|
+
pluginSessionId = pluginSessionId,
|
|
793
927
|
timestamp = tick(),
|
|
794
928
|
}),
|
|
795
929
|
})
|
|
@@ -2827,11 +2961,10 @@ return {
|
|
|
2827
2961
|
<string name="Name">MetadataHandlers</string>
|
|
2828
2962
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
2829
2963
|
local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
2830
|
-
local
|
|
2831
|
-
local CollectionService = _services.CollectionService
|
|
2832
|
-
local LogService = _services.LogService
|
|
2964
|
+
local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
|
|
2833
2965
|
local Utils = TS.import(script, script.Parent.Parent, "Utils")
|
|
2834
2966
|
local Recording = TS.import(script, script.Parent.Parent, "Recording")
|
|
2967
|
+
local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
|
|
2835
2968
|
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
2836
2969
|
local Selection = game:GetService("Selection")
|
|
2837
2970
|
local _binding = Utils
|
|
@@ -3257,137 +3390,11 @@ local function executeLuau(requestData)
|
|
|
3257
3390
|
error = "Code is required",
|
|
3258
3391
|
}
|
|
3259
3392
|
end
|
|
3260
|
-
--
|
|
3261
|
-
--
|
|
3262
|
-
--
|
|
3263
|
-
--
|
|
3264
|
-
|
|
3265
|
-
-- generic "Requested module experienced an error while loading"
|
|
3266
|
-
-- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
|
|
3267
|
-
-- ModuleScript itself returning successfully — the real error +
|
|
3268
|
-
-- traceback live in the returned table.
|
|
3269
|
-
--
|
|
3270
|
-
-- 2. The ModuleScript path runs in its own environment, so a plugin-
|
|
3271
|
-
-- side getfenv print/warn override never reached user prints. A
|
|
3272
|
-
-- lexical local print/warn inside the IIFE captures user prints
|
|
3273
|
-
-- regardless of which path executes. We also call the real global
|
|
3274
|
-
-- print/warn so messages still flow to Studio's output and
|
|
3275
|
-
-- LogService.MessageOut (which powers get_runtime_logs).
|
|
3276
|
-
--
|
|
3277
|
-
-- Prints from required sub-modules don't reach this capture (they have
|
|
3278
|
-
-- their own env) — those go through the runtime log buffer.
|
|
3279
|
-
local wrapped = `return ((function()\
|
|
3280
|
-
\tlocal __mcp_output = \{\}\
|
|
3281
|
-
\tlocal __mcp_real_print = print\
|
|
3282
|
-
\tlocal __mcp_real_warn = warn\
|
|
3283
|
-
\tlocal print = function(...)\
|
|
3284
|
-
\t\t__mcp_real_print(...)\
|
|
3285
|
-
\t\tlocal args = \{...\}\
|
|
3286
|
-
\t\tlocal parts = table.create(#args)\
|
|
3287
|
-
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3288
|
-
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
|
|
3289
|
-
\tend\
|
|
3290
|
-
\tlocal warn = function(...)\
|
|
3291
|
-
\t\t__mcp_real_warn(...)\
|
|
3292
|
-
\t\tlocal args = \{...\}\
|
|
3293
|
-
\t\tlocal parts = table.create(#args)\
|
|
3294
|
-
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3295
|
-
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
|
|
3296
|
-
\tend\
|
|
3297
|
-
\tlocal function __mcp_run()\
|
|
3298
|
-
{code}\
|
|
3299
|
-
\tend\
|
|
3300
|
-
\tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
|
|
3301
|
-
\treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
|
|
3302
|
-
end)())`
|
|
3303
|
-
local runViaModuleScript = function()
|
|
3304
|
-
local m = Instance.new("ModuleScript")
|
|
3305
|
-
m.Name = "__MCPExecLuauPayload"
|
|
3306
|
-
local okSet, setErr = pcall(function()
|
|
3307
|
-
m.Source = wrapped
|
|
3308
|
-
end)
|
|
3309
|
-
if not okSet then
|
|
3310
|
-
m:Destroy()
|
|
3311
|
-
error(`ModuleScript Source set failed: {tostring(setErr)}`)
|
|
3312
|
-
end
|
|
3313
|
-
m.Parent = game:GetService("Workspace")
|
|
3314
|
-
local okReq, reqResult = pcall(function()
|
|
3315
|
-
return require(m)
|
|
3316
|
-
end)
|
|
3317
|
-
m:Destroy()
|
|
3318
|
-
if not okReq then
|
|
3319
|
-
local errMsg = tostring(reqResult)
|
|
3320
|
-
-- pcall(require, m) collapses parse/compile failures into the
|
|
3321
|
-
-- canned engine string below. Walk LogService backward for the
|
|
3322
|
-
-- real diagnostic, which was emitted to MessageOut just before.
|
|
3323
|
-
if errMsg == "Requested module experienced an error while loading" then
|
|
3324
|
-
-- The parser diagnostic is emitted to LogService on the next
|
|
3325
|
-
-- engine frame, not synchronously with pcall(require). task.wait(0)
|
|
3326
|
-
-- yields too early; 50ms is enough to let the frame complete and
|
|
3327
|
-
-- the message land in GetLogHistory.
|
|
3328
|
-
task.wait(0.05)
|
|
3329
|
-
local hist = LogService:GetLogHistory()
|
|
3330
|
-
for i = #hist - 1, 0, -1 do
|
|
3331
|
-
local e = hist[i + 1]
|
|
3332
|
-
if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, 31) == "Workspace.__MCPExecLuauPayload:" then
|
|
3333
|
-
errMsg = e.message
|
|
3334
|
-
break
|
|
3335
|
-
end
|
|
3336
|
-
end
|
|
3337
|
-
end
|
|
3338
|
-
error(errMsg)
|
|
3339
|
-
end
|
|
3340
|
-
return reqResult
|
|
3341
|
-
end
|
|
3342
|
-
local isLoadstringUnavailable = function(err)
|
|
3343
|
-
local errStr = tostring(err)
|
|
3344
|
-
local matchStart = string.find(errStr, "not available", 1, true)
|
|
3345
|
-
return matchStart ~= nil
|
|
3346
|
-
end
|
|
3347
|
-
local success, result = pcall(function()
|
|
3348
|
-
local fn, compileError = loadstring(wrapped)
|
|
3349
|
-
if not fn then
|
|
3350
|
-
if isLoadstringUnavailable(compileError) then
|
|
3351
|
-
return runViaModuleScript()
|
|
3352
|
-
end
|
|
3353
|
-
error(`Compile error: {compileError}`)
|
|
3354
|
-
end
|
|
3355
|
-
return fn()
|
|
3356
|
-
end)
|
|
3357
|
-
-- loadstring throws (not returns nil) in some plugin contexts when
|
|
3358
|
-
-- LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
3359
|
-
if not success and isLoadstringUnavailable(result) then
|
|
3360
|
-
success, result = pcall(runViaModuleScript)
|
|
3361
|
-
end
|
|
3362
|
-
if not success then
|
|
3363
|
-
-- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
3364
|
-
-- error in the user code, or ModuleScript setup error). 'result' is the
|
|
3365
|
-
-- raw error string from pcall.
|
|
3366
|
-
return {
|
|
3367
|
-
success = false,
|
|
3368
|
-
error = tostring(result),
|
|
3369
|
-
output = {},
|
|
3370
|
-
message = "Code execution failed",
|
|
3371
|
-
}
|
|
3372
|
-
end
|
|
3373
|
-
-- Wrapper executed - unpack { ok, value, output }.
|
|
3374
|
-
local r = result
|
|
3375
|
-
local capturedOutput = r.output
|
|
3376
|
-
local output = if capturedOutput ~= nil then capturedOutput else ({})
|
|
3377
|
-
if r.ok == true then
|
|
3378
|
-
return {
|
|
3379
|
-
success = true,
|
|
3380
|
-
returnValue = if r.value ~= nil then tostring(r.value) else nil,
|
|
3381
|
-
output = output,
|
|
3382
|
-
message = "Code executed successfully",
|
|
3383
|
-
}
|
|
3384
|
-
end
|
|
3385
|
-
return {
|
|
3386
|
-
success = false,
|
|
3387
|
-
error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
|
|
3388
|
-
output = output,
|
|
3389
|
-
message = "Code execution failed",
|
|
3390
|
-
}
|
|
3393
|
+
-- All wrapping, print/warn capture, loadstring fallback, JSON-encoding
|
|
3394
|
+
-- of table returns, and parse-error recovery live in LuauExec so the
|
|
3395
|
+
-- edit/server (this handler) and the play-client (ClientBroker) take
|
|
3396
|
+
-- the same code path and produce identical output shapes.
|
|
3397
|
+
return LuauExec.execute(code)
|
|
3391
3398
|
end
|
|
3392
3399
|
local function undo(_requestData)
|
|
3393
3400
|
local success, result = pcall(function()
|
|
@@ -5900,9 +5907,25 @@ local function stopPlaytest(_requestData)
|
|
|
5900
5907
|
}
|
|
5901
5908
|
end
|
|
5902
5909
|
if not StopPlayMonitor.waitForConsumption() then
|
|
5903
|
-
--
|
|
5904
|
-
--
|
|
5910
|
+
-- Two distinct failure modes collapse here, distinguished by whether
|
|
5911
|
+
-- THIS edit DM has a playtest tracked:
|
|
5912
|
+
--
|
|
5913
|
+
-- - testRunning=false: no playtest was running from this edit DM
|
|
5914
|
+
-- (true negative). Return "no active playtest" — fine to retry only
|
|
5915
|
+
-- after actually starting a playtest.
|
|
5916
|
+
-- - testRunning=true: a playtest IS running but the cross-DM signal
|
|
5917
|
+
-- didn't propagate within the consumption timeout (false negative
|
|
5918
|
+
-- from the caller's perspective — playtest may actually have ended).
|
|
5919
|
+
-- Tell the caller it's a timing issue and they can retry.
|
|
5920
|
+
--
|
|
5921
|
+
-- Either way clean up the pending flag so a future playtest's monitor
|
|
5922
|
+
-- doesn't fire EndTest on startup against a stale signal.
|
|
5905
5923
|
StopPlayMonitor.clearPending()
|
|
5924
|
+
if testRunning then
|
|
5925
|
+
return {
|
|
5926
|
+
error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
|
|
5927
|
+
}
|
|
5928
|
+
end
|
|
5906
5929
|
return {
|
|
5907
5930
|
error = "No active playtest to stop.",
|
|
5908
5931
|
}
|
|
@@ -6022,6 +6045,324 @@ return {
|
|
|
6022
6045
|
</Item>
|
|
6023
6046
|
</Item>
|
|
6024
6047
|
<Item class="ModuleScript" referent="19">
|
|
6048
|
+
<Properties>
|
|
6049
|
+
<string name="Name">LuauExec</string>
|
|
6050
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6051
|
+
-- eslint-disable
|
|
6052
|
+
-- Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
|
|
6053
|
+
-- and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
|
|
6054
|
+
-- module owns:
|
|
6055
|
+
--
|
|
6056
|
+
-- 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
|
|
6057
|
+
-- and always returns { ok, value, output } so the ModuleScript itself
|
|
6058
|
+
-- always returns exactly one value (otherwise `print("hi")` with no
|
|
6059
|
+
-- return would fail with "Module code did not return exactly one value").
|
|
6060
|
+
--
|
|
6061
|
+
-- 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
|
|
6062
|
+
-- recovery hack that pulls the real diagnostic from LogService.
|
|
6063
|
+
--
|
|
6064
|
+
-- 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
|
|
6065
|
+
-- caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
|
|
6066
|
+
-- pass through tostring. The encode is pcall'd so cycles or
|
|
6067
|
+
-- non-serializable values gracefully fall back to tostring.
|
|
6068
|
+
--
|
|
6069
|
+
-- Before this module existed, the client peer used a stripped-down
|
|
6070
|
+
-- require-only execution path that lacked both the wrapper and the JSON
|
|
6071
|
+
-- formatting, producing two well-known papercuts:
|
|
6072
|
+
-- - `print("hi")` (no return) failed with "Module code did not return..."
|
|
6073
|
+
-- - Returning a table yielded `table: 0xaddr` instead of structured data.
|
|
6074
|
+
local HttpService = game:GetService("HttpService")
|
|
6075
|
+
local LogService = game:GetService("LogService")
|
|
6076
|
+
local PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload"
|
|
6077
|
+
local PAYLOAD_PATH_PREFIX = `Workspace.{PAYLOAD_INSTANCE_NAME}:`
|
|
6078
|
+
-- Number of lines the wrapper emits BEFORE the first line of user code.
|
|
6079
|
+
-- Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
|
|
6080
|
+
-- (remapPayloadLines, for compile errors recovered from LogService) so user
|
|
6081
|
+
-- code errors report user-relative line numbers instead of the inflated
|
|
6082
|
+
-- "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
|
|
6083
|
+
-- prefix lines, update this constant — there's a self-check below.
|
|
6084
|
+
local WRAPPER_LINE_OFFSET = 23
|
|
6085
|
+
-- Count source lines so the wrapper can filter traceback frames that fall
|
|
6086
|
+
-- outside the user code range (the wrapper's own preamble/postamble lines).
|
|
6087
|
+
local function countLines(s)
|
|
6088
|
+
local n = 1
|
|
6089
|
+
local size = #s
|
|
6090
|
+
do
|
|
6091
|
+
local i = 1
|
|
6092
|
+
local _shouldIncrement = false
|
|
6093
|
+
while true do
|
|
6094
|
+
if _shouldIncrement then
|
|
6095
|
+
i += 1
|
|
6096
|
+
else
|
|
6097
|
+
_shouldIncrement = true
|
|
6098
|
+
end
|
|
6099
|
+
if not (i <= size) then
|
|
6100
|
+
break
|
|
6101
|
+
end
|
|
6102
|
+
if string.sub(s, i, i) == "\n" then
|
|
6103
|
+
n += 1
|
|
6104
|
+
end
|
|
6105
|
+
end
|
|
6106
|
+
end
|
|
6107
|
+
return n
|
|
6108
|
+
end
|
|
6109
|
+
local function buildWrapper(code)
|
|
6110
|
+
-- If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
|
|
6111
|
+
-- match the number of lines emitted BEFORE the ${code} substitution.
|
|
6112
|
+
-- The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
|
|
6113
|
+
-- used by remapPayloadLines on the TS side.
|
|
6114
|
+
local userLines = countLines(code)
|
|
6115
|
+
return `return ((function()\
|
|
6116
|
+
\tlocal __mcp_traceback\
|
|
6117
|
+
\tlocal __mcp_remap\
|
|
6118
|
+
\tlocal __mcp_LINE_OFFSET = {WRAPPER_LINE_OFFSET}\
|
|
6119
|
+
\tlocal __mcp_USER_LINES = {userLines}\
|
|
6120
|
+
\tlocal __mcp_output = \{\}\
|
|
6121
|
+
\tlocal __mcp_real_print = print\
|
|
6122
|
+
\tlocal __mcp_real_warn = warn\
|
|
6123
|
+
\tlocal print = function(...)\
|
|
6124
|
+
\t\t__mcp_real_print(...)\
|
|
6125
|
+
\t\tlocal args = \{...\}\
|
|
6126
|
+
\t\tlocal parts = table.create(#args)\
|
|
6127
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
6128
|
+
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
|
|
6129
|
+
\tend\
|
|
6130
|
+
\tlocal warn = function(...)\
|
|
6131
|
+
\t\t__mcp_real_warn(...)\
|
|
6132
|
+
\t\tlocal args = \{...\}\
|
|
6133
|
+
\t\tlocal parts = table.create(#args)\
|
|
6134
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
6135
|
+
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
|
|
6136
|
+
\tend\
|
|
6137
|
+
\tlocal function __mcp_run()\
|
|
6138
|
+
{code}\
|
|
6139
|
+
\tend\
|
|
6140
|
+
\t__mcp_remap = function(s)\
|
|
6141
|
+
\t\t-- Two chunk-name formats can reference our payload:\
|
|
6142
|
+
\t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path\
|
|
6143
|
+
\t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)\
|
|
6144
|
+
\t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.\
|
|
6145
|
+
\t\t-- Clamping matters for unclosed constructs ("local x = (") where the\
|
|
6146
|
+
\t\t-- parser keeps reading into wrapper postamble and reports a payload\
|
|
6147
|
+
\t\t-- line past user EOF. Without clamping the message says "user_code:49"\
|
|
6148
|
+
\t\t-- for one-line input, framing the wrapper as user code.\
|
|
6149
|
+
\t\tlocal function __mcp_user_line(payload_n)\
|
|
6150
|
+
\t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET\
|
|
6151
|
+
\t\t\tif user_n < 1 then return "1" end\
|
|
6152
|
+
\t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end\
|
|
6153
|
+
\t\t\treturn tostring(user_n)\
|
|
6154
|
+
\t\tend\
|
|
6155
|
+
\t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)\
|
|
6156
|
+
\t\t\tlocal n = tonumber(num)\
|
|
6157
|
+
\t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
|
|
6158
|
+
\t\t\treturn "user_code:" .. num\
|
|
6159
|
+
\t\tend)\
|
|
6160
|
+
\t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)\
|
|
6161
|
+
\t\t\tlocal n = tonumber(num)\
|
|
6162
|
+
\t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
|
|
6163
|
+
\t\t\treturn "user_code:" .. num\
|
|
6164
|
+
\t\tend)\
|
|
6165
|
+
\t\treturn s\
|
|
6166
|
+
\tend\
|
|
6167
|
+
\t__mcp_traceback = function(err)\
|
|
6168
|
+
\t\tlocal raw = debug.traceback(tostring(err), 2)\
|
|
6169
|
+
\t\tlocal kept = \{\}\
|
|
6170
|
+
\t\tfor line in string.gmatch(raw, "[^\\n]+") do\
|
|
6171
|
+
\t\t\t-- Extract referenced line number (either chunk-name format).\
|
|
6172
|
+
\t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")\
|
|
6173
|
+
\t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')\
|
|
6174
|
+
\t\t\tlocal n = num_str and tonumber(num_str)\
|
|
6175
|
+
\t\t\t-- Strip the "in function '__mcp_run'" annotation before doing\
|
|
6176
|
+
\t\t\t-- any filtering, because user-code frames carry that suffix —\
|
|
6177
|
+
\t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY\
|
|
6178
|
+
\t\t\t-- user frame would otherwise match a naive "__mcp_" filter and\
|
|
6179
|
+
\t\t\t-- get dropped. Strip first, then apply filters.\
|
|
6180
|
+
\t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))\
|
|
6181
|
+
\t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)\
|
|
6182
|
+
\t\t\t\tor string.find(line, "__mcp_", 1, true)\
|
|
6183
|
+
\t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)\
|
|
6184
|
+
\t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside\
|
|
6185
|
+
\t\t\t-- user range) are wrapper internals — drop them. Lines without\
|
|
6186
|
+
\t\t\t-- a payload-chunk line number (the traceback header / engine\
|
|
6187
|
+
\t\t\t-- C frames) are kept; remap is a no-op for them.\
|
|
6188
|
+
\t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then\
|
|
6189
|
+
\t\t\t\tskip = true\
|
|
6190
|
+
\t\t\tend\
|
|
6191
|
+
\t\t\tif not skip then\
|
|
6192
|
+
\t\t\t\ttable.insert(kept, __mcp_remap(line))\
|
|
6193
|
+
\t\t\tend\
|
|
6194
|
+
\t\tend\
|
|
6195
|
+
\t\treturn table.concat(kept, "\\n")\
|
|
6196
|
+
\tend\
|
|
6197
|
+
\tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)\
|
|
6198
|
+
\treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
|
|
6199
|
+
end)())`
|
|
6200
|
+
end
|
|
6201
|
+
-- TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
|
|
6202
|
+
-- pulling the real compile-error diagnostic out of LogService — that error
|
|
6203
|
+
-- references the payload module's line number directly, and never passes
|
|
6204
|
+
-- through the IIFE's runtime wrapper.
|
|
6205
|
+
local function remapPayloadLines(s, userLines)
|
|
6206
|
+
-- Mirror of the Lua __mcp_remap inside the wrapper, for paths that
|
|
6207
|
+
-- don't pass through the IIFE (compile errors recovered from
|
|
6208
|
+
-- LogService, the immediate loadstring compileError surface). Same
|
|
6209
|
+
-- two-format coverage plus the same clamp: unclosed user constructs
|
|
6210
|
+
-- let the parser consume wrapper postamble, so the raw payload line
|
|
6211
|
+
-- is sometimes well past user EOF — clamp to [1, userLines] and
|
|
6212
|
+
-- annotate so the error doesn't say "user_code:49" for one-line input.
|
|
6213
|
+
local userLine = function(payload)
|
|
6214
|
+
local u = payload - WRAPPER_LINE_OFFSET
|
|
6215
|
+
if u < 1 then
|
|
6216
|
+
return "1"
|
|
6217
|
+
end
|
|
6218
|
+
if u > userLines then
|
|
6219
|
+
return `{tostring(userLines)} (at end of input)`
|
|
6220
|
+
end
|
|
6221
|
+
return tostring(u)
|
|
6222
|
+
end
|
|
6223
|
+
local out = s
|
|
6224
|
+
local a = string.gsub(out, "__MCPExecLuauPayload:(%d+)", function(num)
|
|
6225
|
+
local n = tonumber(num)
|
|
6226
|
+
if n ~= nil then
|
|
6227
|
+
return `user_code:{userLine(n)}`
|
|
6228
|
+
end
|
|
6229
|
+
return `user_code:{num}`
|
|
6230
|
+
end)
|
|
6231
|
+
out = a
|
|
6232
|
+
local b = string.gsub(out, '%[string "[^"]+"%]:(%d+)', function(num)
|
|
6233
|
+
local n = tonumber(num)
|
|
6234
|
+
if n ~= nil then
|
|
6235
|
+
return `user_code:{userLine(n)}`
|
|
6236
|
+
end
|
|
6237
|
+
return `user_code:{num}`
|
|
6238
|
+
end)
|
|
6239
|
+
out = b
|
|
6240
|
+
return out
|
|
6241
|
+
end
|
|
6242
|
+
local function runViaModuleScript(wrapped, userLines)
|
|
6243
|
+
local m = Instance.new("ModuleScript")
|
|
6244
|
+
m.Name = PAYLOAD_INSTANCE_NAME
|
|
6245
|
+
local okSet, setErr = pcall(function()
|
|
6246
|
+
m.Source = wrapped
|
|
6247
|
+
end)
|
|
6248
|
+
if not okSet then
|
|
6249
|
+
m:Destroy()
|
|
6250
|
+
-- error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
|
|
6251
|
+
-- prefix that error() would otherwise prepend, keeping the visible
|
|
6252
|
+
-- message focused on the user-actionable error rather than our path.
|
|
6253
|
+
error(`ModuleScript Source set failed: {tostring(setErr)}`, 0)
|
|
6254
|
+
end
|
|
6255
|
+
m.Parent = game:GetService("Workspace")
|
|
6256
|
+
local okReq, reqResult = pcall(function()
|
|
6257
|
+
return require(m)
|
|
6258
|
+
end)
|
|
6259
|
+
m:Destroy()
|
|
6260
|
+
if not okReq then
|
|
6261
|
+
local errMsg = tostring(reqResult)
|
|
6262
|
+
-- pcall(require, m) collapses parse/compile failures into the canned
|
|
6263
|
+
-- engine string. The real diagnostic was emitted to LogService on the
|
|
6264
|
+
-- next engine frame — give it ~50ms to land then scan backward.
|
|
6265
|
+
if errMsg == "Requested module experienced an error while loading" then
|
|
6266
|
+
task.wait(0.05)
|
|
6267
|
+
local hist = LogService:GetLogHistory()
|
|
6268
|
+
for i = #hist - 1, 0, -1 do
|
|
6269
|
+
local e = hist[i + 1]
|
|
6270
|
+
if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, #PAYLOAD_PATH_PREFIX) == PAYLOAD_PATH_PREFIX then
|
|
6271
|
+
errMsg = e.message
|
|
6272
|
+
break
|
|
6273
|
+
end
|
|
6274
|
+
end
|
|
6275
|
+
end
|
|
6276
|
+
-- Compile errors reference the payload module's line number directly
|
|
6277
|
+
-- — remap + clamp to user-relative line numbers so `local x = 1 +`
|
|
6278
|
+
-- reports :1: instead of :23:, and reports the clamp annotation
|
|
6279
|
+
-- when the parser ran off the end of user code into wrapper code.
|
|
6280
|
+
error(remapPayloadLines(errMsg, userLines), 0)
|
|
6281
|
+
end
|
|
6282
|
+
return reqResult
|
|
6283
|
+
end
|
|
6284
|
+
local function isLoadstringUnavailable(err)
|
|
6285
|
+
local errStr = tostring(err)
|
|
6286
|
+
local matchStart = string.find(errStr, "not available", 1, true)
|
|
6287
|
+
return matchStart ~= nil
|
|
6288
|
+
end
|
|
6289
|
+
-- Returns a string suitable for `returnValue`. Tables get JSON-encoded so
|
|
6290
|
+
-- the caller sees structured data instead of "table: 0xaddr". Anything that
|
|
6291
|
+
-- JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
|
|
6292
|
+
local function formatReturnValue(value)
|
|
6293
|
+
if value == nil then
|
|
6294
|
+
return ""
|
|
6295
|
+
end
|
|
6296
|
+
local _value = value
|
|
6297
|
+
if type(_value) == "table" then
|
|
6298
|
+
local ok, encoded = pcall(function()
|
|
6299
|
+
return HttpService:JSONEncode(value)
|
|
6300
|
+
end)
|
|
6301
|
+
if ok then
|
|
6302
|
+
return encoded
|
|
6303
|
+
end
|
|
6304
|
+
end
|
|
6305
|
+
return tostring(value)
|
|
6306
|
+
end
|
|
6307
|
+
local function execute(code)
|
|
6308
|
+
if not (code ~= "" and code) or code == "" then
|
|
6309
|
+
return {
|
|
6310
|
+
success = false,
|
|
6311
|
+
error = "code is required",
|
|
6312
|
+
}
|
|
6313
|
+
end
|
|
6314
|
+
local wrapped = buildWrapper(code)
|
|
6315
|
+
local userLines = countLines(code)
|
|
6316
|
+
local success, result = pcall(function()
|
|
6317
|
+
local fn, compileError = loadstring(wrapped)
|
|
6318
|
+
if not fn then
|
|
6319
|
+
if isLoadstringUnavailable(compileError) then
|
|
6320
|
+
return runViaModuleScript(wrapped, userLines)
|
|
6321
|
+
end
|
|
6322
|
+
error(`Compile error: {remapPayloadLines(tostring(compileError), userLines)}`, 0)
|
|
6323
|
+
end
|
|
6324
|
+
return fn()
|
|
6325
|
+
end)
|
|
6326
|
+
-- loadstring can throw (not return nil) when ServerScriptService.
|
|
6327
|
+
-- LoadStringEnabled is false; treat that as a second-chance fallback.
|
|
6328
|
+
if not success and isLoadstringUnavailable(result) then
|
|
6329
|
+
success, result = pcall(function()
|
|
6330
|
+
return runViaModuleScript(wrapped, userLines)
|
|
6331
|
+
end)
|
|
6332
|
+
end
|
|
6333
|
+
if not success then
|
|
6334
|
+
return {
|
|
6335
|
+
success = false,
|
|
6336
|
+
error = tostring(result),
|
|
6337
|
+
output = {},
|
|
6338
|
+
message = "Code execution failed",
|
|
6339
|
+
}
|
|
6340
|
+
end
|
|
6341
|
+
local r = result
|
|
6342
|
+
local capturedOutput = r.output
|
|
6343
|
+
local output = if capturedOutput ~= nil then capturedOutput else ({})
|
|
6344
|
+
if r.ok == true then
|
|
6345
|
+
return {
|
|
6346
|
+
success = true,
|
|
6347
|
+
returnValue = if r.value ~= nil then formatReturnValue(r.value) else nil,
|
|
6348
|
+
output = output,
|
|
6349
|
+
message = "Code executed successfully",
|
|
6350
|
+
}
|
|
6351
|
+
end
|
|
6352
|
+
return {
|
|
6353
|
+
success = false,
|
|
6354
|
+
error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
|
|
6355
|
+
output = output,
|
|
6356
|
+
message = "Code execution failed",
|
|
6357
|
+
}
|
|
6358
|
+
end
|
|
6359
|
+
return {
|
|
6360
|
+
execute = execute,
|
|
6361
|
+
}
|
|
6362
|
+
]]></string>
|
|
6363
|
+
</Properties>
|
|
6364
|
+
</Item>
|
|
6365
|
+
<Item class="ModuleScript" referent="20">
|
|
6025
6366
|
<Properties>
|
|
6026
6367
|
<string name="Name">Recording</string>
|
|
6027
6368
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6051,7 +6392,7 @@ return {
|
|
|
6051
6392
|
]]></string>
|
|
6052
6393
|
</Properties>
|
|
6053
6394
|
</Item>
|
|
6054
|
-
<Item class="ModuleScript" referent="
|
|
6395
|
+
<Item class="ModuleScript" referent="21">
|
|
6055
6396
|
<Properties>
|
|
6056
6397
|
<string name="Name">RuntimeLogBuffer</string>
|
|
6057
6398
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -6232,11 +6573,11 @@ return {
|
|
|
6232
6573
|
]]></string>
|
|
6233
6574
|
</Properties>
|
|
6234
6575
|
</Item>
|
|
6235
|
-
<Item class="ModuleScript" referent="
|
|
6576
|
+
<Item class="ModuleScript" referent="22">
|
|
6236
6577
|
<Properties>
|
|
6237
6578
|
<string name="Name">State</string>
|
|
6238
6579
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6239
|
-
local CURRENT_VERSION = "2.
|
|
6580
|
+
local CURRENT_VERSION = "2.12.0"
|
|
6240
6581
|
local MAX_CONNECTIONS = 5
|
|
6241
6582
|
local BASE_PORT = 58741
|
|
6242
6583
|
local activeTabIndex = 0
|
|
@@ -6328,61 +6669,96 @@ return {
|
|
|
6328
6669
|
]]></string>
|
|
6329
6670
|
</Properties>
|
|
6330
6671
|
</Item>
|
|
6331
|
-
<Item class="ModuleScript" referent="
|
|
6672
|
+
<Item class="ModuleScript" referent="23">
|
|
6332
6673
|
<Properties>
|
|
6333
6674
|
<string name="Name">StopPlayMonitor</string>
|
|
6334
6675
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6335
|
-
|
|
6676
|
+
local TS = require(script.Parent.Parent.include.RuntimeLib)
|
|
6677
|
+
-- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
|
|
6678
|
+
-- per-instance setting key so the same Studio process can host playtests
|
|
6679
|
+
-- for multiple places without one place's stop_playtest yanking another's.
|
|
6336
6680
|
--
|
|
6337
6681
|
-- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
6338
|
-
--
|
|
6339
|
-
-- play-
|
|
6340
|
-
--
|
|
6682
|
+
-- shared across every DataModel the plugin runs in (edit DMs, play-server
|
|
6683
|
+
-- DMs, play-client DMs). For each connected place we use a dedicated key
|
|
6684
|
+
-- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
|
|
6341
6685
|
--
|
|
6342
|
-
-- * The edit DM's stopPlaytest handler writes
|
|
6343
|
-
--
|
|
6344
|
-
--
|
|
6345
|
-
--
|
|
6346
|
-
--
|
|
6347
|
-
--
|
|
6686
|
+
-- * The edit DM's stopPlaytest handler writes `true` into its own key
|
|
6687
|
+
-- (computed from its placeId / ServerStorage anon UUID).
|
|
6688
|
+
-- * Each play-server DM's monitor loop polls the key matching its own
|
|
6689
|
+
-- instanceId at 0.1Hz; on `true` it clears the key and calls
|
|
6690
|
+
-- StudioTestService:EndTest. Play-server DMs for other places never
|
|
6691
|
+
-- touch this key.
|
|
6692
|
+
-- * The edit DM waits up to ~8s for its key to be cleared, confirming a
|
|
6693
|
+
-- matching play-server actually consumed the request.
|
|
6348
6694
|
--
|
|
6349
|
-
--
|
|
6350
|
-
--
|
|
6351
|
-
--
|
|
6352
|
-
--
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
-- Pattern mirrors the official Roblox Studio MCP
|
|
6357
|
-
-- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
|
|
6695
|
+
-- Earlier versions used a single shared boolean flag, which let any
|
|
6696
|
+
-- play-server DM in the same Studio process consume any place's stop
|
|
6697
|
+
-- request — silently yanking teammates' playtests. The per-key scoping
|
|
6698
|
+
-- below is the fix.
|
|
6699
|
+
local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
6700
|
+
local HttpService = _services.HttpService
|
|
6701
|
+
local ServerStorage = _services.ServerStorage
|
|
6358
6702
|
local StudioTestService = game:GetService("StudioTestService")
|
|
6359
|
-
local
|
|
6360
|
-
|
|
6361
|
-
|
|
6703
|
+
local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
|
|
6704
|
+
-- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
|
|
6705
|
+
-- lag tight so the consumption-confirmation window doesn't have to absorb
|
|
6706
|
+
-- polling jitter on top of EndTest's teardown time.
|
|
6707
|
+
local POLL_INTERVAL_SEC = 0.1
|
|
6708
|
+
-- Total time we wait for the matching play-server DM to consume the
|
|
6709
|
+
-- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
|
|
6710
|
+
-- StudioTestService:EndTest teardown (several seconds on heavier places).
|
|
6711
|
+
-- 8s is comfortable; the tighter poll above keeps real cases well under.
|
|
6712
|
+
local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
|
|
6362
6713
|
local WAIT_POLL_SEC = 0.1
|
|
6363
6714
|
local pluginRef
|
|
6364
6715
|
local function init(p)
|
|
6365
6716
|
pluginRef = p
|
|
6366
6717
|
end
|
|
6718
|
+
-- Mirror of Communication.computeInstanceId(). Duplicated here because
|
|
6719
|
+
-- StopPlayMonitor runs in both edit and play-server DMs, and both must
|
|
6720
|
+
-- agree on the place identifier (published places: placeId; unpublished:
|
|
6721
|
+
-- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
|
|
6722
|
+
-- into the play DM).
|
|
6723
|
+
local function computeInstanceId()
|
|
6724
|
+
if game.PlaceId ~= 0 then
|
|
6725
|
+
return `place:{tostring(game.PlaceId)}`
|
|
6726
|
+
end
|
|
6727
|
+
local existing = ServerStorage:GetAttribute("__MCPPlaceId")
|
|
6728
|
+
if type(existing) == "string" and existing ~= "" then
|
|
6729
|
+
return `anon:{existing}`
|
|
6730
|
+
end
|
|
6731
|
+
local fresh = HttpService:GenerateGUID(false)
|
|
6732
|
+
pcall(function()
|
|
6733
|
+
return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
|
|
6734
|
+
end)
|
|
6735
|
+
return `anon:{fresh}`
|
|
6736
|
+
end
|
|
6737
|
+
local function settingKey(instanceId)
|
|
6738
|
+
return SETTING_KEY_PREFIX .. instanceId
|
|
6739
|
+
end
|
|
6367
6740
|
local function startMonitor()
|
|
6368
6741
|
if not pluginRef then
|
|
6369
6742
|
warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
|
|
6370
6743
|
return nil
|
|
6371
6744
|
end
|
|
6372
|
-
|
|
6373
|
-
--
|
|
6374
|
-
--
|
|
6745
|
+
local myKey = settingKey(computeInstanceId())
|
|
6746
|
+
-- Clear any stale value left from a prior session. If a real stop
|
|
6747
|
+
-- request is in-flight when this runs, the requesting edit DM will
|
|
6748
|
+
-- write again within its consumption-confirmation window.
|
|
6375
6749
|
pcall(function()
|
|
6376
|
-
return pluginRef:SetSetting(
|
|
6750
|
+
return pluginRef:SetSetting(myKey, false)
|
|
6377
6751
|
end)
|
|
6378
6752
|
task.spawn(function()
|
|
6379
6753
|
while true do
|
|
6380
6754
|
local okGet, val = pcall(function()
|
|
6381
|
-
return pluginRef:GetSetting(
|
|
6755
|
+
return pluginRef:GetSetting(myKey)
|
|
6382
6756
|
end)
|
|
6383
6757
|
if okGet and val == true then
|
|
6758
|
+
-- Consume the flag first so requestStop's
|
|
6759
|
+
-- waitForConsumption returns success, then end the test.
|
|
6384
6760
|
pcall(function()
|
|
6385
|
-
return pluginRef:SetSetting(
|
|
6761
|
+
return pluginRef:SetSetting(myKey, false)
|
|
6386
6762
|
end)
|
|
6387
6763
|
pcall(function()
|
|
6388
6764
|
return StudioTestService:EndTest("stopped_by_mcp")
|
|
@@ -6396,8 +6772,9 @@ local function requestStop()
|
|
|
6396
6772
|
if not pluginRef then
|
|
6397
6773
|
return false
|
|
6398
6774
|
end
|
|
6775
|
+
local myKey = settingKey(computeInstanceId())
|
|
6399
6776
|
local ok = pcall(function()
|
|
6400
|
-
return pluginRef:SetSetting(
|
|
6777
|
+
return pluginRef:SetSetting(myKey, true)
|
|
6401
6778
|
end)
|
|
6402
6779
|
return ok
|
|
6403
6780
|
end
|
|
@@ -6405,10 +6782,11 @@ local function waitForConsumption()
|
|
|
6405
6782
|
if not pluginRef then
|
|
6406
6783
|
return false
|
|
6407
6784
|
end
|
|
6785
|
+
local myKey = settingKey(computeInstanceId())
|
|
6408
6786
|
local start = tick()
|
|
6409
6787
|
while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
|
|
6410
6788
|
local okGet, val = pcall(function()
|
|
6411
|
-
return pluginRef:GetSetting(
|
|
6789
|
+
return pluginRef:GetSetting(myKey)
|
|
6412
6790
|
end)
|
|
6413
6791
|
if okGet and val ~= true then
|
|
6414
6792
|
return true
|
|
@@ -6421,8 +6799,9 @@ local function clearPending()
|
|
|
6421
6799
|
if not pluginRef then
|
|
6422
6800
|
return nil
|
|
6423
6801
|
end
|
|
6802
|
+
local myKey = settingKey(computeInstanceId())
|
|
6424
6803
|
pcall(function()
|
|
6425
|
-
return pluginRef:SetSetting(
|
|
6804
|
+
return pluginRef:SetSetting(myKey, false)
|
|
6426
6805
|
end)
|
|
6427
6806
|
end
|
|
6428
6807
|
return {
|
|
@@ -6435,7 +6814,7 @@ return {
|
|
|
6435
6814
|
]]></string>
|
|
6436
6815
|
</Properties>
|
|
6437
6816
|
</Item>
|
|
6438
|
-
<Item class="ModuleScript" referent="
|
|
6817
|
+
<Item class="ModuleScript" referent="24">
|
|
6439
6818
|
<Properties>
|
|
6440
6819
|
<string name="Name">UI</string>
|
|
6441
6820
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7186,7 +7565,7 @@ return {
|
|
|
7186
7565
|
]]></string>
|
|
7187
7566
|
</Properties>
|
|
7188
7567
|
</Item>
|
|
7189
|
-
<Item class="ModuleScript" referent="
|
|
7568
|
+
<Item class="ModuleScript" referent="25">
|
|
7190
7569
|
<Properties>
|
|
7191
7570
|
<string name="Name">Utils</string>
|
|
7192
7571
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7716,11 +8095,11 @@ return {
|
|
|
7716
8095
|
</Properties>
|
|
7717
8096
|
</Item>
|
|
7718
8097
|
</Item>
|
|
7719
|
-
<Item class="Folder" referent="
|
|
8098
|
+
<Item class="Folder" referent="29">
|
|
7720
8099
|
<Properties>
|
|
7721
8100
|
<string name="Name">include</string>
|
|
7722
8101
|
</Properties>
|
|
7723
|
-
<Item class="ModuleScript" referent="
|
|
8102
|
+
<Item class="ModuleScript" referent="26">
|
|
7724
8103
|
<Properties>
|
|
7725
8104
|
<string name="Name">Promise</string>
|
|
7726
8105
|
<string name="Source"><![CDATA[--[[
|
|
@@ -9794,7 +10173,7 @@ return Promise
|
|
|
9794
10173
|
]]></string>
|
|
9795
10174
|
</Properties>
|
|
9796
10175
|
</Item>
|
|
9797
|
-
<Item class="ModuleScript" referent="
|
|
10176
|
+
<Item class="ModuleScript" referent="27">
|
|
9798
10177
|
<Properties>
|
|
9799
10178
|
<string name="Name">RuntimeLib</string>
|
|
9800
10179
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -10061,15 +10440,15 @@ return TS
|
|
|
10061
10440
|
</Properties>
|
|
10062
10441
|
</Item>
|
|
10063
10442
|
</Item>
|
|
10064
|
-
<Item class="Folder" referent="
|
|
10443
|
+
<Item class="Folder" referent="30">
|
|
10065
10444
|
<Properties>
|
|
10066
10445
|
<string name="Name">node_modules</string>
|
|
10067
10446
|
</Properties>
|
|
10068
|
-
<Item class="Folder" referent="
|
|
10447
|
+
<Item class="Folder" referent="31">
|
|
10069
10448
|
<Properties>
|
|
10070
10449
|
<string name="Name">@rbxts</string>
|
|
10071
10450
|
</Properties>
|
|
10072
|
-
<Item class="ModuleScript" referent="
|
|
10451
|
+
<Item class="ModuleScript" referent="28">
|
|
10073
10452
|
<Properties>
|
|
10074
10453
|
<string name="Name">services</string>
|
|
10075
10454
|
<string name="Source"><![CDATA[return setmetatable({}, {
|