@chrrxs/robloxstudio-mcp 2.11.1 → 2.11.3
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 +77 -21
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +266 -147
- package/studio-plugin/MCPPlugin.rbxmx +266 -147
- package/studio-plugin/src/modules/ClientBroker.ts +8 -50
- package/studio-plugin/src/modules/StopPlayMonitor.ts +87 -0
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +77 -54
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +63 -23
- package/studio-plugin/src/server/index.server.ts +10 -0
|
@@ -11,10 +11,15 @@ 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
13
|
local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
|
|
14
|
+
local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
|
|
14
15
|
-- Attach the per-peer LogService.MessageOut listener as early as possible so
|
|
15
16
|
-- boot-time prints from the user's place scripts are captured. Powers the
|
|
16
17
|
-- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
|
|
17
18
|
RuntimeLogBuffer.install()
|
|
19
|
+
-- Share the plugin reference with the stop-play signaling module so both the
|
|
20
|
+
-- edit DM (write the flag) and the play-server DM (read+act on the flag) can
|
|
21
|
+
-- access plugin:SetSetting/GetSetting.
|
|
22
|
+
StopPlayMonitor.init(plugin)
|
|
18
23
|
UI.init(plugin)
|
|
19
24
|
local elements = UI.getElements()
|
|
20
25
|
local ICON_DISCONNECTED = "rbxassetid://125921838360800"
|
|
@@ -65,6 +70,10 @@ task.delay(2, function()
|
|
|
65
70
|
end
|
|
66
71
|
if role == "server" then
|
|
67
72
|
ClientBroker.setupServerBroker()
|
|
73
|
+
-- The play-server DM is the only one where StudioTestService:EndTest is
|
|
74
|
+
-- legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
|
|
75
|
+
-- at 1Hz and calls EndTest when the edit DM sets it.
|
|
76
|
+
StopPlayMonitor.startMonitor()
|
|
68
77
|
elseif role == "client" then
|
|
69
78
|
ClientBroker.setupClientBroker()
|
|
70
79
|
end
|
|
@@ -94,12 +103,10 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
|
|
|
94
103
|
-- in ReplicatedStorage; each player gets a proxy "client" registration on the
|
|
95
104
|
-- MCP side, polled and dispatched by the server peer.
|
|
96
105
|
--
|
|
97
|
-
--
|
|
98
|
-
-- /api/stop-playtest
|
|
99
|
-
--
|
|
100
|
-
--
|
|
101
|
-
-- /responds, so non-stop edit-targeted requests fall through to the actual
|
|
102
|
-
-- edit DM untouched.
|
|
106
|
+
-- (Previously the server peer also registered an "edit-proxy" role to
|
|
107
|
+
-- intercept /api/stop-playtest and call StudioTestService:EndTest. That hack
|
|
108
|
+
-- is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
|
|
109
|
+
-- signaling, which works regardless of MCP server state.)
|
|
103
110
|
local MCP_URL = "http://localhost:58741"
|
|
104
111
|
local BROKER_NAME = "__MCPClientBroker"
|
|
105
112
|
-- Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
@@ -329,59 +336,10 @@ local function registerProxy(player, rf)
|
|
|
329
336
|
proxyByPlayer[_player_1] = _arg1
|
|
330
337
|
task.spawn(pollProxy, proxyId, player, rf)
|
|
331
338
|
end
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
instanceId = proxyId,
|
|
337
|
-
role = "edit-proxy",
|
|
338
|
-
})
|
|
339
|
-
if not ok or not res or not res.Success then
|
|
340
|
-
warn("[MCPFork] edit-proxy register failed")
|
|
341
|
-
return nil
|
|
342
|
-
end
|
|
343
|
-
while true do
|
|
344
|
-
local okPoll, pollRes = pcall(function()
|
|
345
|
-
return HttpService:RequestAsync({
|
|
346
|
-
Url = `{MCP_URL}/poll?instanceId={proxyId}`,
|
|
347
|
-
Method = "GET",
|
|
348
|
-
Headers = {
|
|
349
|
-
["Content-Type"] = "application/json",
|
|
350
|
-
},
|
|
351
|
-
})
|
|
352
|
-
end)
|
|
353
|
-
if okPoll and pollRes and (pollRes.Success or pollRes.StatusCode == 503) then
|
|
354
|
-
local okJson, body = pcall(function()
|
|
355
|
-
return HttpService:JSONDecode(pollRes.Body)
|
|
356
|
-
end)
|
|
357
|
-
if okJson and body then
|
|
358
|
-
-- Re-register if the server lost our edit-proxy registration.
|
|
359
|
-
if body.knownInstance == false then
|
|
360
|
-
reRegisterProxy(proxyId, "edit-proxy")
|
|
361
|
-
end
|
|
362
|
-
if body.request and body.request.endpoint == "/api/stop-playtest" and body.requestId ~= nil then
|
|
363
|
-
local sts = game:GetService("StudioTestService")
|
|
364
|
-
local endOk, endErr = pcall(function()
|
|
365
|
-
return sts:EndTest("stopped_by_mcp")
|
|
366
|
-
end)
|
|
367
|
-
local response = if endOk then {
|
|
368
|
-
success = true,
|
|
369
|
-
message = "Playtest stopped via edit-proxy/EndTest",
|
|
370
|
-
} else {
|
|
371
|
-
success = false,
|
|
372
|
-
error = `EndTest failed: {tostring(endErr)}`,
|
|
373
|
-
}
|
|
374
|
-
postJson("/response", {
|
|
375
|
-
requestId = body.requestId,
|
|
376
|
-
response = response,
|
|
377
|
-
})
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
end
|
|
381
|
-
task.wait(0.15)
|
|
382
|
-
end
|
|
383
|
-
end)
|
|
384
|
-
end
|
|
339
|
+
-- (Removed: startEditProxyLoop. The play-server DM no longer registers an
|
|
340
|
+
-- "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
|
|
341
|
+
-- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
|
|
342
|
+
-- which doesn't depend on MCP server state or peer registration at all.)
|
|
385
343
|
local function setupServerBroker()
|
|
386
344
|
local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
|
|
387
345
|
if not rf then
|
|
@@ -415,7 +373,6 @@ local function setupServerBroker()
|
|
|
415
373
|
end
|
|
416
374
|
table.clear(proxyByPlayer)
|
|
417
375
|
end)
|
|
418
|
-
startEditProxyLoop()
|
|
419
376
|
end
|
|
420
377
|
return {
|
|
421
378
|
MCP_URL = MCP_URL,
|
|
@@ -3298,56 +3255,52 @@ local function executeLuau(requestData)
|
|
|
3298
3255
|
error = "Code is required",
|
|
3299
3256
|
}
|
|
3300
3257
|
end
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3258
|
+
-- Both execution paths (loadstring + ModuleScript-require fallback) run
|
|
3259
|
+
-- the SAME wrapped source so they return a uniform { ok, value, output }
|
|
3260
|
+
-- shape. Two problems the wrapper solves at once:
|
|
3261
|
+
--
|
|
3262
|
+
-- 1. pcall(require, m) swallows the real error and returns Roblox's
|
|
3263
|
+
-- generic "Requested module experienced an error while loading"
|
|
3264
|
+
-- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
|
|
3265
|
+
-- ModuleScript itself returning successfully — the real error +
|
|
3266
|
+
-- traceback live in the returned table.
|
|
3267
|
+
--
|
|
3268
|
+
-- 2. The ModuleScript path runs in its own environment, so a plugin-
|
|
3269
|
+
-- side getfenv print/warn override never reached user prints. A
|
|
3270
|
+
-- lexical local print/warn inside the IIFE captures user prints
|
|
3271
|
+
-- regardless of which path executes. We also call the real global
|
|
3272
|
+
-- print/warn so messages still flow to Studio's output and
|
|
3273
|
+
-- LogService.MessageOut (which powers get_runtime_logs).
|
|
3274
|
+
--
|
|
3275
|
+
-- Prints from required sub-modules don't reach this capture (they have
|
|
3276
|
+
-- their own env) — those go through the runtime log buffer.
|
|
3277
|
+
local wrapped = `return ((function()\
|
|
3278
|
+
\tlocal __mcp_output = \{\}\
|
|
3279
|
+
\tlocal __mcp_real_print = print\
|
|
3280
|
+
\tlocal __mcp_real_warn = warn\
|
|
3281
|
+
\tlocal print = function(...)\
|
|
3282
|
+
\t\t__mcp_real_print(...)\
|
|
3283
|
+
\t\tlocal args = \{...\}\
|
|
3284
|
+
\t\tlocal parts = table.create(#args)\
|
|
3285
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3286
|
+
\t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
|
|
3287
|
+
\tend\
|
|
3288
|
+
\tlocal warn = function(...)\
|
|
3289
|
+
\t\t__mcp_real_warn(...)\
|
|
3290
|
+
\t\tlocal args = \{...\}\
|
|
3291
|
+
\t\tlocal parts = table.create(#args)\
|
|
3292
|
+
\t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
|
|
3293
|
+
\t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
|
|
3294
|
+
\tend\
|
|
3295
|
+
\tlocal function __mcp_run()\
|
|
3296
|
+
{code}\
|
|
3297
|
+
\tend\
|
|
3298
|
+
\tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
|
|
3299
|
+
\treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
|
|
3300
|
+
end)())`
|
|
3337
3301
|
local runViaModuleScript = function()
|
|
3338
3302
|
local m = Instance.new("ModuleScript")
|
|
3339
3303
|
m.Name = "__MCPExecLuauPayload"
|
|
3340
|
-
-- Wrap user code in an IIFE so require() always gets exactly one
|
|
3341
|
-
-- return value. Without this, code like `print("x")` errors with
|
|
3342
|
-
-- "Module code did not return exactly one value" because top-level
|
|
3343
|
-
-- ModuleScripts must return exactly one value.
|
|
3344
|
-
--
|
|
3345
|
-
-- The DOUBLE parens around the call are load-bearing: in Luau,
|
|
3346
|
-
-- `return f()` propagates whatever multi-value tuple f returns,
|
|
3347
|
-
-- including zero values. Outer parens adjust the call to exactly
|
|
3348
|
-
-- one value (the first, or nil). So `return ((f)())` always
|
|
3349
|
-
-- returns exactly one value, regardless of what f does.
|
|
3350
|
-
local wrapped = `return ((function()\n{code}\nend)())`
|
|
3351
3304
|
local okSet, setErr = pcall(function()
|
|
3352
3305
|
m.Source = wrapped
|
|
3353
3306
|
end)
|
|
@@ -3371,7 +3324,7 @@ local function executeLuau(requestData)
|
|
|
3371
3324
|
return matchStart ~= nil
|
|
3372
3325
|
end
|
|
3373
3326
|
local success, result = pcall(function()
|
|
3374
|
-
local fn, compileError = loadstring(
|
|
3327
|
+
local fn, compileError = loadstring(wrapped)
|
|
3375
3328
|
if not fn then
|
|
3376
3329
|
if isLoadstringUnavailable(compileError) then
|
|
3377
3330
|
return runViaModuleScript()
|
|
@@ -3381,27 +3334,39 @@ local function executeLuau(requestData)
|
|
|
3381
3334
|
return fn()
|
|
3382
3335
|
end)
|
|
3383
3336
|
-- loadstring throws (not returns nil) in some plugin contexts when
|
|
3384
|
-
-- LoadStringEnabled=false. Catch that
|
|
3337
|
+
-- LoadStringEnabled=false. Catch that as a second-chance fallback.
|
|
3385
3338
|
if not success and isLoadstringUnavailable(result) then
|
|
3386
3339
|
success, result = pcall(runViaModuleScript)
|
|
3387
3340
|
end
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
success = true,
|
|
3393
|
-
returnValue = if result ~= nil then tostring(result) else nil,
|
|
3394
|
-
output = output,
|
|
3395
|
-
message = "Code executed successfully",
|
|
3396
|
-
}
|
|
3397
|
-
else
|
|
3341
|
+
if not success then
|
|
3342
|
+
-- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
|
|
3343
|
+
-- error in the user code, or ModuleScript setup error). 'result' is the
|
|
3344
|
+
-- raw error string from pcall.
|
|
3398
3345
|
return {
|
|
3399
3346
|
success = false,
|
|
3400
3347
|
error = tostring(result),
|
|
3401
|
-
output =
|
|
3348
|
+
output = {},
|
|
3402
3349
|
message = "Code execution failed",
|
|
3403
3350
|
}
|
|
3404
3351
|
end
|
|
3352
|
+
-- Wrapper executed - unpack { ok, value, output }.
|
|
3353
|
+
local r = result
|
|
3354
|
+
local capturedOutput = r.output
|
|
3355
|
+
local output = if capturedOutput ~= nil then capturedOutput else ({})
|
|
3356
|
+
if r.ok == true then
|
|
3357
|
+
return {
|
|
3358
|
+
success = true,
|
|
3359
|
+
returnValue = if r.value ~= nil then tostring(r.value) else nil,
|
|
3360
|
+
output = output,
|
|
3361
|
+
message = "Code executed successfully",
|
|
3362
|
+
}
|
|
3363
|
+
end
|
|
3364
|
+
return {
|
|
3365
|
+
success = false,
|
|
3366
|
+
error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
|
|
3367
|
+
output = output,
|
|
3368
|
+
message = "Code execution failed",
|
|
3369
|
+
}
|
|
3405
3370
|
end
|
|
3406
3371
|
local function undo(_requestData)
|
|
3407
3372
|
local success, result = pcall(function()
|
|
@@ -5686,13 +5651,18 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
|
|
|
5686
5651
|
local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
|
|
5687
5652
|
local HttpService = _services.HttpService
|
|
5688
5653
|
local LogService = _services.LogService
|
|
5654
|
+
local RunService = _services.RunService
|
|
5689
5655
|
local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
|
|
5690
5656
|
local installBridges = _EvalBridges.installBridges
|
|
5691
5657
|
local cleanupBridges = _EvalBridges.cleanupBridges
|
|
5658
|
+
local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
|
|
5692
5659
|
local StudioTestService = game:GetService("StudioTestService")
|
|
5693
5660
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
5694
5661
|
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
5695
|
-
|
|
5662
|
+
-- NAV_SIGNAL flows from the edit DM to the play-server DM via the injected
|
|
5663
|
+
-- __MCP_CommandListener Script + LogService.MessageOut. Stop signaling moved
|
|
5664
|
+
-- off this path entirely (see StopPlayMonitor) because cross-DM MessageOut
|
|
5665
|
+
-- reflection from edit -> play-server does not work in practice.
|
|
5696
5666
|
local NAV_SIGNAL = "__MCP_NAV__"
|
|
5697
5667
|
local NAV_RESULT = "__MCP_NAV_RESULT__"
|
|
5698
5668
|
local testRunning = false
|
|
@@ -5704,16 +5674,13 @@ local stopListenerScript
|
|
|
5704
5674
|
local navResultCallback
|
|
5705
5675
|
local function buildCommandListenerSource()
|
|
5706
5676
|
return `local LogService = game:GetService("LogService")\
|
|
5707
|
-
local StudioTestService = game:GetService("StudioTestService")\
|
|
5708
5677
|
local PathfindingService = game:GetService("PathfindingService")\
|
|
5709
5678
|
local Players = game:GetService("Players")\
|
|
5710
5679
|
local HttpService = game:GetService("HttpService")\
|
|
5711
5680
|
local NAV_SIG = "{NAV_SIGNAL}"\
|
|
5712
5681
|
local NAV_RES = "{NAV_RESULT}"\
|
|
5713
5682
|
LogService.MessageOut:Connect(function(msg)\
|
|
5714
|
-
if msg == "
|
|
5715
|
-
pcall(function() StudioTestService:EndTest("stopped_by_mcp") end)\
|
|
5716
|
-
elseif string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
|
|
5683
|
+
if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
|
|
5717
5684
|
local json = string.sub(msg, #NAV_SIG + 2)\
|
|
5718
5685
|
task.spawn(function()\
|
|
5719
5686
|
local ok, d = pcall(function() return HttpService:JSONDecode(json) end)\
|
|
@@ -5801,6 +5768,19 @@ local function startPlaytest(requestData)
|
|
|
5801
5768
|
error = 'mode must be "play" or "run"',
|
|
5802
5769
|
}
|
|
5803
5770
|
end
|
|
5771
|
+
-- Self-heal: if testRunning is stuck true but Studio reports no active
|
|
5772
|
+
-- playtest, the previous start_playtest's task.spawn was orphaned
|
|
5773
|
+
-- (plugin reload mid-test, Studio entered some inconsistent state, etc).
|
|
5774
|
+
-- Reset it so subsequent starts don't hit a false "already running".
|
|
5775
|
+
if testRunning and not RunService:IsRunning() then
|
|
5776
|
+
testRunning = false
|
|
5777
|
+
if logConnection then
|
|
5778
|
+
logConnection:Disconnect()
|
|
5779
|
+
logConnection = nil
|
|
5780
|
+
end
|
|
5781
|
+
cleanupStopListener()
|
|
5782
|
+
cleanupBridges()
|
|
5783
|
+
end
|
|
5804
5784
|
if testRunning then
|
|
5805
5785
|
return {
|
|
5806
5786
|
error = "A test is already running",
|
|
@@ -5812,9 +5792,6 @@ local function startPlaytest(requestData)
|
|
|
5812
5792
|
testError = nil
|
|
5813
5793
|
cleanupStopListener()
|
|
5814
5794
|
logConnection = LogService.MessageOut:Connect(function(message, messageType)
|
|
5815
|
-
if message == STOP_SIGNAL then
|
|
5816
|
-
return nil
|
|
5817
|
-
end
|
|
5818
5795
|
local _message = message
|
|
5819
5796
|
local _arg1 = #NAV_SIGNAL
|
|
5820
5797
|
if string.sub(_message, 1, _arg1) == NAV_SIGNAL then
|
|
@@ -5876,25 +5853,60 @@ local function startPlaytest(requestData)
|
|
|
5876
5853
|
cleanupStopListener()
|
|
5877
5854
|
cleanupBridges()
|
|
5878
5855
|
end)
|
|
5879
|
-
local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s)
|
|
5856
|
+
local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
|
|
5880
5857
|
local response = {
|
|
5881
5858
|
success = true,
|
|
5882
5859
|
message = msg,
|
|
5883
|
-
evalBridges = if bridgeInstall.installed then "installed" else `failed: {bridgeInstall.error}`,
|
|
5884
5860
|
}
|
|
5861
|
+
-- Only mention eval bridges when they failed — when they're fine, the
|
|
5862
|
+
-- detail is noise. eval_server_runtime / eval_client_runtime will surface
|
|
5863
|
+
-- their own clear errors if the caller tries to use them after a failed
|
|
5864
|
+
-- install.
|
|
5865
|
+
if not bridgeInstall.installed then
|
|
5866
|
+
response.evalBridgesError = bridgeInstall.error
|
|
5867
|
+
end
|
|
5885
5868
|
return response
|
|
5886
5869
|
end
|
|
5887
5870
|
local function stopPlaytest(_requestData)
|
|
5888
|
-
--
|
|
5889
|
-
--
|
|
5890
|
-
--
|
|
5891
|
-
-- the
|
|
5892
|
-
--
|
|
5893
|
-
|
|
5894
|
-
|
|
5871
|
+
-- Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
|
|
5872
|
+
-- cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
|
|
5873
|
+
-- calls StudioTestService:EndTest, then resets the flag. We wait up to
|
|
5874
|
+
-- 2.5s for the reset to confirm a play DM actually consumed the request,
|
|
5875
|
+
-- which avoids returning success when nothing is running.
|
|
5876
|
+
if not StopPlayMonitor.requestStop() then
|
|
5877
|
+
return {
|
|
5878
|
+
error = "Plugin not ready. Try again in a moment.",
|
|
5879
|
+
}
|
|
5880
|
+
end
|
|
5881
|
+
if not StopPlayMonitor.waitForConsumption() then
|
|
5882
|
+
-- Clean up the pending flag so a future playtest's monitor doesn't fire
|
|
5883
|
+
-- EndTest on its own startup against a stale signal.
|
|
5884
|
+
StopPlayMonitor.clearPending()
|
|
5885
|
+
return {
|
|
5886
|
+
error = "No active playtest to stop.",
|
|
5887
|
+
}
|
|
5888
|
+
end
|
|
5889
|
+
-- Flag was consumed (EndTest called). ExecutePlayModeAsync in our
|
|
5890
|
+
-- startPlaytest task.spawn is still unwinding though — testRunning stays
|
|
5891
|
+
-- true until that yield completes and the post-block runs. Wait so
|
|
5892
|
+
-- back-to-back stop -> start sequences don't race against the prior
|
|
5893
|
+
-- teardown and get "A test is already running". 10s covers play-DM
|
|
5894
|
+
-- teardown on heavier places; if it still hasn't cleared we return
|
|
5895
|
+
-- anyway so users aren't stuck — but note that in the response so the
|
|
5896
|
+
-- caller knows a subsequent start may need a moment.
|
|
5897
|
+
local deadline = tick() + 10
|
|
5898
|
+
while testRunning and tick() < deadline do
|
|
5899
|
+
task.wait(0.1)
|
|
5900
|
+
end
|
|
5901
|
+
if testRunning then
|
|
5902
|
+
return {
|
|
5903
|
+
success = true,
|
|
5904
|
+
message = "Playtest stop signal sent; teardown still in progress.",
|
|
5905
|
+
}
|
|
5906
|
+
end
|
|
5895
5907
|
return {
|
|
5896
|
-
|
|
5897
|
-
|
|
5908
|
+
success = true,
|
|
5909
|
+
message = "Playtest stopped.",
|
|
5898
5910
|
}
|
|
5899
5911
|
end
|
|
5900
5912
|
local function getPlaytestOutput(_requestData)
|
|
@@ -6203,7 +6215,7 @@ return {
|
|
|
6203
6215
|
<Properties>
|
|
6204
6216
|
<string name="Name">State</string>
|
|
6205
6217
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6206
|
-
local CURRENT_VERSION = "2.11.
|
|
6218
|
+
local CURRENT_VERSION = "2.11.3"
|
|
6207
6219
|
local MAX_CONNECTIONS = 5
|
|
6208
6220
|
local BASE_PORT = 58741
|
|
6209
6221
|
local activeTabIndex = 0
|
|
@@ -6296,6 +6308,113 @@ return {
|
|
|
6296
6308
|
</Properties>
|
|
6297
6309
|
</Item>
|
|
6298
6310
|
<Item class="ModuleScript" referent="22">
|
|
6311
|
+
<Properties>
|
|
6312
|
+
<string name="Name">StopPlayMonitor</string>
|
|
6313
|
+
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
6314
|
+
-- Cross-DM stop_playtest signaling via plugin:SetSetting.
|
|
6315
|
+
--
|
|
6316
|
+
-- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
|
|
6317
|
+
-- that's shared across every DataModel the plugin runs in (edit, play-server,
|
|
6318
|
+
-- play-clients). We use it as a one-bit flag for "please call EndTest in the
|
|
6319
|
+
-- play-server DM":
|
|
6320
|
+
--
|
|
6321
|
+
-- * The edit DM's stopPlaytest handler writes the flag (requestStop).
|
|
6322
|
+
-- * A monitor loop running inside the play-server DM polls the flag at 1Hz
|
|
6323
|
+
-- and calls StudioTestService:EndTest when it flips true, then resets it.
|
|
6324
|
+
-- * The edit DM then waits up to ~2.5s for the flag to be reset, which
|
|
6325
|
+
-- tells us a play-server actually consumed the request (no false-positive
|
|
6326
|
+
-- success when nothing was running).
|
|
6327
|
+
--
|
|
6328
|
+
-- Why this is simpler than the previous edit-proxy registration:
|
|
6329
|
+
-- * Doesn't depend on the MCP server tracking peer roles at all.
|
|
6330
|
+
-- * Survives MCP server restarts: monitor loop is local to the play-server
|
|
6331
|
+
-- plugin lifetime, not to any HTTP/registration state.
|
|
6332
|
+
-- * No need for cross-DM LogService.MessageOut reflection (which we verified
|
|
6333
|
+
-- does not work edit -> play-server anyway).
|
|
6334
|
+
--
|
|
6335
|
+
-- Pattern mirrors the official Roblox Studio MCP
|
|
6336
|
+
-- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
|
|
6337
|
+
local StudioTestService = game:GetService("StudioTestService")
|
|
6338
|
+
local SETTING_KEY = "MCP_STOP_PLAY_SIGNAL"
|
|
6339
|
+
local POLL_INTERVAL_SEC = 1
|
|
6340
|
+
local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5
|
|
6341
|
+
local WAIT_POLL_SEC = 0.1
|
|
6342
|
+
local pluginRef
|
|
6343
|
+
local function init(p)
|
|
6344
|
+
pluginRef = p
|
|
6345
|
+
end
|
|
6346
|
+
local function startMonitor()
|
|
6347
|
+
if not pluginRef then
|
|
6348
|
+
warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
|
|
6349
|
+
return nil
|
|
6350
|
+
end
|
|
6351
|
+
-- Clear any stale value left from a prior session. If a real stop request
|
|
6352
|
+
-- is in-flight when this runs, the requesting edit DM will set it again
|
|
6353
|
+
-- within its 2.5s wait window.
|
|
6354
|
+
pcall(function()
|
|
6355
|
+
return pluginRef:SetSetting(SETTING_KEY, false)
|
|
6356
|
+
end)
|
|
6357
|
+
task.spawn(function()
|
|
6358
|
+
while true do
|
|
6359
|
+
local okGet, val = pcall(function()
|
|
6360
|
+
return pluginRef:GetSetting(SETTING_KEY)
|
|
6361
|
+
end)
|
|
6362
|
+
if okGet and val == true then
|
|
6363
|
+
pcall(function()
|
|
6364
|
+
return pluginRef:SetSetting(SETTING_KEY, false)
|
|
6365
|
+
end)
|
|
6366
|
+
pcall(function()
|
|
6367
|
+
return StudioTestService:EndTest("stopped_by_mcp")
|
|
6368
|
+
end)
|
|
6369
|
+
end
|
|
6370
|
+
task.wait(POLL_INTERVAL_SEC)
|
|
6371
|
+
end
|
|
6372
|
+
end)
|
|
6373
|
+
end
|
|
6374
|
+
local function requestStop()
|
|
6375
|
+
if not pluginRef then
|
|
6376
|
+
return false
|
|
6377
|
+
end
|
|
6378
|
+
local ok = pcall(function()
|
|
6379
|
+
return pluginRef:SetSetting(SETTING_KEY, true)
|
|
6380
|
+
end)
|
|
6381
|
+
return ok
|
|
6382
|
+
end
|
|
6383
|
+
local function waitForConsumption()
|
|
6384
|
+
if not pluginRef then
|
|
6385
|
+
return false
|
|
6386
|
+
end
|
|
6387
|
+
local start = tick()
|
|
6388
|
+
while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
|
|
6389
|
+
local okGet, val = pcall(function()
|
|
6390
|
+
return pluginRef:GetSetting(SETTING_KEY)
|
|
6391
|
+
end)
|
|
6392
|
+
if okGet and val ~= true then
|
|
6393
|
+
return true
|
|
6394
|
+
end
|
|
6395
|
+
task.wait(WAIT_POLL_SEC)
|
|
6396
|
+
end
|
|
6397
|
+
return false
|
|
6398
|
+
end
|
|
6399
|
+
local function clearPending()
|
|
6400
|
+
if not pluginRef then
|
|
6401
|
+
return nil
|
|
6402
|
+
end
|
|
6403
|
+
pcall(function()
|
|
6404
|
+
return pluginRef:SetSetting(SETTING_KEY, false)
|
|
6405
|
+
end)
|
|
6406
|
+
end
|
|
6407
|
+
return {
|
|
6408
|
+
init = init,
|
|
6409
|
+
startMonitor = startMonitor,
|
|
6410
|
+
requestStop = requestStop,
|
|
6411
|
+
waitForConsumption = waitForConsumption,
|
|
6412
|
+
clearPending = clearPending,
|
|
6413
|
+
}
|
|
6414
|
+
]]></string>
|
|
6415
|
+
</Properties>
|
|
6416
|
+
</Item>
|
|
6417
|
+
<Item class="ModuleScript" referent="23">
|
|
6299
6418
|
<Properties>
|
|
6300
6419
|
<string name="Name">UI</string>
|
|
6301
6420
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7046,7 +7165,7 @@ return {
|
|
|
7046
7165
|
]]></string>
|
|
7047
7166
|
</Properties>
|
|
7048
7167
|
</Item>
|
|
7049
|
-
<Item class="ModuleScript" referent="
|
|
7168
|
+
<Item class="ModuleScript" referent="24">
|
|
7050
7169
|
<Properties>
|
|
7051
7170
|
<string name="Name">Utils</string>
|
|
7052
7171
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
@@ -7576,11 +7695,11 @@ return {
|
|
|
7576
7695
|
</Properties>
|
|
7577
7696
|
</Item>
|
|
7578
7697
|
</Item>
|
|
7579
|
-
<Item class="Folder" referent="
|
|
7698
|
+
<Item class="Folder" referent="28">
|
|
7580
7699
|
<Properties>
|
|
7581
7700
|
<string name="Name">include</string>
|
|
7582
7701
|
</Properties>
|
|
7583
|
-
<Item class="ModuleScript" referent="
|
|
7702
|
+
<Item class="ModuleScript" referent="25">
|
|
7584
7703
|
<Properties>
|
|
7585
7704
|
<string name="Name">Promise</string>
|
|
7586
7705
|
<string name="Source"><![CDATA[--[[
|
|
@@ -9654,7 +9773,7 @@ return Promise
|
|
|
9654
9773
|
]]></string>
|
|
9655
9774
|
</Properties>
|
|
9656
9775
|
</Item>
|
|
9657
|
-
<Item class="ModuleScript" referent="
|
|
9776
|
+
<Item class="ModuleScript" referent="26">
|
|
9658
9777
|
<Properties>
|
|
9659
9778
|
<string name="Name">RuntimeLib</string>
|
|
9660
9779
|
<string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
|
|
@@ -9921,15 +10040,15 @@ return TS
|
|
|
9921
10040
|
</Properties>
|
|
9922
10041
|
</Item>
|
|
9923
10042
|
</Item>
|
|
9924
|
-
<Item class="Folder" referent="
|
|
10043
|
+
<Item class="Folder" referent="29">
|
|
9925
10044
|
<Properties>
|
|
9926
10045
|
<string name="Name">node_modules</string>
|
|
9927
10046
|
</Properties>
|
|
9928
|
-
<Item class="Folder" referent="
|
|
10047
|
+
<Item class="Folder" referent="30">
|
|
9929
10048
|
<Properties>
|
|
9930
10049
|
<string name="Name">@rbxts</string>
|
|
9931
10050
|
</Properties>
|
|
9932
|
-
<Item class="ModuleScript" referent="
|
|
10051
|
+
<Item class="ModuleScript" referent="27">
|
|
9933
10052
|
<Properties>
|
|
9934
10053
|
<string name="Name">services</string>
|
|
9935
10054
|
<string name="Source"><![CDATA[return setmetatable({}, {
|