rbxstudio-mcp 2.2.2 → 2.2.4
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/LICENSE +0 -0
- package/README.md +0 -0
- package/dist/__tests__/bridge-service.test.d.ts +0 -0
- package/dist/__tests__/bridge-service.test.d.ts.map +0 -0
- package/dist/__tests__/bridge-service.test.js +0 -0
- package/dist/__tests__/bridge-service.test.js.map +0 -0
- package/dist/__tests__/http-server.test.d.ts +0 -0
- package/dist/__tests__/http-server.test.d.ts.map +0 -0
- package/dist/__tests__/http-server.test.js +0 -0
- package/dist/__tests__/http-server.test.js.map +0 -0
- package/dist/__tests__/integration.test.d.ts +0 -0
- package/dist/__tests__/integration.test.d.ts.map +0 -0
- package/dist/__tests__/integration.test.js +0 -0
- package/dist/__tests__/integration.test.js.map +0 -0
- package/dist/__tests__/smoke.test.d.ts +0 -0
- package/dist/__tests__/smoke.test.d.ts.map +0 -0
- package/dist/__tests__/smoke.test.js +0 -0
- package/dist/__tests__/smoke.test.js.map +0 -0
- package/dist/bridge-service.d.ts +94 -0
- package/dist/bridge-service.d.ts.map +1 -1
- package/dist/bridge-service.js +188 -1
- package/dist/bridge-service.js.map +1 -1
- package/dist/http-server.d.ts +0 -0
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +52 -0
- package/dist/http-server.js.map +1 -1
- package/dist/index.d.ts +0 -0
- package/dist/index.d.ts.map +0 -0
- package/dist/index.js +27 -2
- package/dist/index.js.map +1 -1
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +121 -10
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/studio-client.d.ts +0 -0
- package/dist/tools/studio-client.d.ts.map +0 -0
- package/dist/tools/studio-client.js +0 -0
- package/dist/tools/studio-client.js.map +0 -0
- package/package.json +1 -1
- package/studio-plugin/INSTALLATION.md +0 -0
- package/studio-plugin/MCPPlugin.rbxmx +0 -0
- package/studio-plugin/plugin.json +0 -0
- package/studio-plugin/plugin.luau +348 -90
|
@@ -7,12 +7,240 @@ local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
|
7
7
|
local CollectionService = game:GetService("CollectionService")
|
|
8
8
|
local LogService = game:GetService("LogService")
|
|
9
9
|
|
|
10
|
-
-- StudioTestService for play/stop control (may not be available in all contexts)
|
|
10
|
+
-- StudioTestService for play/stop control (may not be available in all contexts).
|
|
11
|
+
-- NOTE: The previous version of this file mistakenly fetched "TestService" here
|
|
12
|
+
-- — that's a different (legacy) service. The actual API for ExecutePlayModeAsync
|
|
13
|
+
-- and EndTest lives on "StudioTestService". The playSolo handler also re-fetches
|
|
14
|
+
-- this defensively so an older Studio doesn't blow up at plugin load time.
|
|
11
15
|
local StudioTestService = nil
|
|
12
16
|
pcall(function()
|
|
13
|
-
StudioTestService = game:GetService("
|
|
17
|
+
StudioTestService = game:GetService("StudioTestService")
|
|
14
18
|
end)
|
|
15
19
|
|
|
20
|
+
-- ============================================================================
|
|
21
|
+
-- TEST SESSION COMPANION
|
|
22
|
+
--
|
|
23
|
+
-- StudioTestService:EndTest() can only be called from the Server DataModel of
|
|
24
|
+
-- a running test. The plugin lives in the Edit DataModel, so it cannot call
|
|
25
|
+
-- EndTest itself. To work around this we inject a small Script into
|
|
26
|
+
-- ServerScriptService BEFORE calling ExecutePlayModeAsync. When the test
|
|
27
|
+
-- starts, that Script runs in the Server DataModel and:
|
|
28
|
+
-- 1. Streams LogService.MessageOut to the bridge (/test-session/log)
|
|
29
|
+
-- 2. Polls the bridge (/test-session/poll) for an "end" command
|
|
30
|
+
-- 3. On "end", calls StudioTestService:EndTest() — which terminates the
|
|
31
|
+
-- yielding ExecutePlayModeAsync call back in the plugin.
|
|
32
|
+
--
|
|
33
|
+
-- Cleanup strategy:
|
|
34
|
+
-- - Tagged with TEST_COMPANION_TAG so we can find orphans
|
|
35
|
+
-- - Swept on plugin activate, on every playSolo start, and after the test
|
|
36
|
+
-- ends (whether via stop_play, natural end, or user clicking Stop)
|
|
37
|
+
-- ============================================================================
|
|
38
|
+
local TEST_COMPANION_NAME = "_MCPTestCompanion"
|
|
39
|
+
local TEST_COMPANION_TAG = "_MCPTestCompanion"
|
|
40
|
+
|
|
41
|
+
-- The companion script source. Two placeholders are substituted at injection
|
|
42
|
+
-- time: __MCP_SERVER_URL__ and __MCP_SESSION_ID__. We use distinctive
|
|
43
|
+
-- placeholders (not %%...%% or {{...}}) to avoid collisions with Lua's own
|
|
44
|
+
-- pattern syntax during gsub.
|
|
45
|
+
local COMPANION_SCRIPT_TEMPLATE = [[-- _MCPTestCompanion (auto-generated, safe to delete)
|
|
46
|
+
-- Runs in the Server DataModel of a Studio test session started by the MCP
|
|
47
|
+
-- plugin. Pipes output back to the local MCP bridge and listens for an "end"
|
|
48
|
+
-- command to terminate the test cleanly via StudioTestService:EndTest().
|
|
49
|
+
|
|
50
|
+
local HttpService = game:GetService("HttpService")
|
|
51
|
+
local LogService = game:GetService("LogService")
|
|
52
|
+
local StudioTestService
|
|
53
|
+
do
|
|
54
|
+
local ok, svc = pcall(function() return game:GetService("StudioTestService") end)
|
|
55
|
+
if ok then StudioTestService = svc end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local SERVER_URL = "__MCP_SERVER_URL__"
|
|
59
|
+
local SESSION_ID = "__MCP_SESSION_ID__"
|
|
60
|
+
local POLL_INTERVAL = 0.4
|
|
61
|
+
local LOG_FLUSH_INTERVAL = 0.25
|
|
62
|
+
local MAX_BUFFER = 5000
|
|
63
|
+
local MAX_CONSECUTIVE_FAILURES = 30 -- ~12s of failed polls before we give up
|
|
64
|
+
|
|
65
|
+
local outputBuffer = {}
|
|
66
|
+
local stopRequested = false
|
|
67
|
+
local consecutiveFailures = 0
|
|
68
|
+
|
|
69
|
+
local function postJSON(path, body)
|
|
70
|
+
local ok, response = pcall(function()
|
|
71
|
+
return HttpService:RequestAsync({
|
|
72
|
+
Url = SERVER_URL .. path,
|
|
73
|
+
Method = "POST",
|
|
74
|
+
Headers = { ["Content-Type"] = "application/json" },
|
|
75
|
+
Body = HttpService:JSONEncode(body),
|
|
76
|
+
})
|
|
77
|
+
end)
|
|
78
|
+
if ok and response and response.Success then
|
|
79
|
+
consecutiveFailures = 0
|
|
80
|
+
return true, response
|
|
81
|
+
else
|
|
82
|
+
consecutiveFailures += 1
|
|
83
|
+
return false, nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
LogService.MessageOut:Connect(function(message, messageType)
|
|
88
|
+
if #outputBuffer >= MAX_BUFFER then
|
|
89
|
+
-- Drop oldest to keep buffer bounded; we'd rather lose stale output
|
|
90
|
+
-- than balloon memory in a long-running test.
|
|
91
|
+
table.remove(outputBuffer, 1)
|
|
92
|
+
end
|
|
93
|
+
table.insert(outputBuffer, {
|
|
94
|
+
message = tostring(message),
|
|
95
|
+
messageType = messageType.Name,
|
|
96
|
+
timestamp = os.time(),
|
|
97
|
+
})
|
|
98
|
+
end)
|
|
99
|
+
|
|
100
|
+
-- Log flusher
|
|
101
|
+
task.spawn(function()
|
|
102
|
+
while not stopRequested do
|
|
103
|
+
if #outputBuffer > 0 then
|
|
104
|
+
local toSend = outputBuffer
|
|
105
|
+
outputBuffer = {}
|
|
106
|
+
local ok = postJSON("/test-session/log", {
|
|
107
|
+
sessionId = SESSION_ID,
|
|
108
|
+
messages = toSend,
|
|
109
|
+
})
|
|
110
|
+
if not ok then
|
|
111
|
+
-- Re-queue (capped) so we don't lose log lines on a transient
|
|
112
|
+
-- bridge hiccup. Newer entries take priority since they're
|
|
113
|
+
-- usually what the AI wants to see.
|
|
114
|
+
for i = 1, math.min(#toSend, MAX_BUFFER - #outputBuffer) do
|
|
115
|
+
table.insert(outputBuffer, toSend[i])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
task.wait(LOG_FLUSH_INTERVAL)
|
|
120
|
+
end
|
|
121
|
+
end)
|
|
122
|
+
|
|
123
|
+
-- Command poller
|
|
124
|
+
task.spawn(function()
|
|
125
|
+
while not stopRequested and consecutiveFailures < MAX_CONSECUTIVE_FAILURES do
|
|
126
|
+
local ok, response = postJSON("/test-session/poll", { sessionId = SESSION_ID })
|
|
127
|
+
if ok and response and response.Body then
|
|
128
|
+
local decodeOk, body = pcall(function()
|
|
129
|
+
return HttpService:JSONDecode(response.Body)
|
|
130
|
+
end)
|
|
131
|
+
if decodeOk and body then
|
|
132
|
+
-- Bridge tells us the session is over (e.g. user clicked Stop
|
|
133
|
+
-- in Studio, or the plugin was reloaded). Stop polling.
|
|
134
|
+
if body.ended then
|
|
135
|
+
stopRequested = true
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
if body.command and body.command.cmd == "end" then
|
|
139
|
+
stopRequested = true
|
|
140
|
+
-- Final flush of any pending output before we tear down.
|
|
141
|
+
if #outputBuffer > 0 then
|
|
142
|
+
local final = outputBuffer
|
|
143
|
+
outputBuffer = {}
|
|
144
|
+
postJSON("/test-session/log", {
|
|
145
|
+
sessionId = SESSION_ID,
|
|
146
|
+
messages = final,
|
|
147
|
+
})
|
|
148
|
+
end
|
|
149
|
+
-- Tell the bridge we're acknowledging the stop. This wakes
|
|
150
|
+
-- any stop_play caller waiting on a confirmation.
|
|
151
|
+
postJSON("/test-session/ended", {
|
|
152
|
+
sessionId = SESSION_ID,
|
|
153
|
+
reason = "mcp_stop",
|
|
154
|
+
})
|
|
155
|
+
-- Finally, end the test. This terminates the yielding
|
|
156
|
+
-- ExecutePlayModeAsync call back in the Edit-side plugin.
|
|
157
|
+
if StudioTestService then
|
|
158
|
+
pcall(function()
|
|
159
|
+
StudioTestService:EndTest(body.command.args or "MCP_Stop")
|
|
160
|
+
end)
|
|
161
|
+
end
|
|
162
|
+
return
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
task.wait(POLL_INTERVAL)
|
|
167
|
+
end
|
|
168
|
+
end)
|
|
169
|
+
]]
|
|
170
|
+
|
|
171
|
+
-- gsub-safe replacement (treat replacement as plain string, not a pattern).
|
|
172
|
+
local function plainReplace(haystack, needle, replacement)
|
|
173
|
+
-- Use string.find with plain=true to avoid pattern interpretation in needle.
|
|
174
|
+
local result = {}
|
|
175
|
+
local i = 1
|
|
176
|
+
while true do
|
|
177
|
+
local s, e = string.find(haystack, needle, i, true)
|
|
178
|
+
if not s then
|
|
179
|
+
table.insert(result, string.sub(haystack, i))
|
|
180
|
+
break
|
|
181
|
+
end
|
|
182
|
+
table.insert(result, string.sub(haystack, i, s - 1))
|
|
183
|
+
table.insert(result, replacement)
|
|
184
|
+
i = e + 1
|
|
185
|
+
end
|
|
186
|
+
return table.concat(result)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
local function cleanupTestCompanions()
|
|
190
|
+
local count = 0
|
|
191
|
+
pcall(function()
|
|
192
|
+
for _, inst in ipairs(CollectionService:GetTagged(TEST_COMPANION_TAG)) do
|
|
193
|
+
pcall(function() inst:Destroy() end)
|
|
194
|
+
count += 1
|
|
195
|
+
end
|
|
196
|
+
end)
|
|
197
|
+
-- Belt & suspenders: also remove any name-matching script in
|
|
198
|
+
-- ServerScriptService in case the tag was somehow stripped.
|
|
199
|
+
pcall(function()
|
|
200
|
+
local sss = game:GetService("ServerScriptService")
|
|
201
|
+
for _, child in ipairs(sss:GetChildren()) do
|
|
202
|
+
if child.Name == TEST_COMPANION_NAME then
|
|
203
|
+
pcall(function() child:Destroy() end)
|
|
204
|
+
count += 1
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end)
|
|
208
|
+
return count
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
local function injectTestCompanion(serverUrl, sessionId)
|
|
212
|
+
-- Always sweep before creating to guarantee at most one companion exists.
|
|
213
|
+
cleanupTestCompanions()
|
|
214
|
+
|
|
215
|
+
local source = COMPANION_SCRIPT_TEMPLATE
|
|
216
|
+
source = plainReplace(source, "__MCP_SERVER_URL__", serverUrl)
|
|
217
|
+
source = plainReplace(source, "__MCP_SESSION_ID__", sessionId)
|
|
218
|
+
|
|
219
|
+
local script = Instance.new("Script")
|
|
220
|
+
script.Name = TEST_COMPANION_NAME
|
|
221
|
+
script.Source = source
|
|
222
|
+
-- IMPORTANT: Archivable MUST stay true (the default).
|
|
223
|
+
-- ExecutePlayModeAsync builds the test DataModel by Clone()-ing the Edit
|
|
224
|
+
-- DataModel, and Instance:Clone() silently returns nil for any instance
|
|
225
|
+
-- with Archivable=false. We previously set this to false as a "safety"
|
|
226
|
+
-- against the companion getting saved into the user's place file — but
|
|
227
|
+
-- the side effect was that the companion never made it into the test
|
|
228
|
+
-- session at all. No companion → no log streaming and no way to receive
|
|
229
|
+
-- the "end" command, so stop_play would just time out.
|
|
230
|
+
--
|
|
231
|
+
-- We rely on the cleanup sweeps (on plugin activate, before each test,
|
|
232
|
+
-- after each test) to keep companions out of the saved file. The script
|
|
233
|
+
-- is also tagged with TEST_COMPANION_TAG so any orphan is easy to find.
|
|
234
|
+
script.Archivable = true
|
|
235
|
+
script.Parent = game:GetService("ServerScriptService")
|
|
236
|
+
|
|
237
|
+
pcall(function()
|
|
238
|
+
CollectionService:AddTag(script, TEST_COMPANION_TAG)
|
|
239
|
+
end)
|
|
240
|
+
|
|
241
|
+
return script
|
|
242
|
+
end
|
|
243
|
+
|
|
16
244
|
-- Track output log for get_output
|
|
17
245
|
local outputBuffer = {}
|
|
18
246
|
local MAX_OUTPUT_BUFFER = 1000
|
|
@@ -4312,123 +4540,143 @@ end
|
|
|
4312
4540
|
|
|
4313
4541
|
-- ============================================
|
|
4314
4542
|
-- PLAYTEST CONTROL HANDLERS
|
|
4543
|
+
--
|
|
4544
|
+
-- play_solo: receives a sessionId from the bridge, injects a companion
|
|
4545
|
+
-- Script tagged with TEST_COMPANION_TAG into ServerScriptService, then
|
|
4546
|
+
-- spawns ExecutePlayModeAsync in a separate thread so the HTTP response
|
|
4547
|
+
-- can return immediately. When the test ends (for any reason), we sweep
|
|
4548
|
+
-- the companion and notify the bridge.
|
|
4549
|
+
--
|
|
4550
|
+
-- stop_play: this handler is kept only for backward compatibility. The
|
|
4551
|
+
-- real stop path is the bridge enqueueing an "end" command that the
|
|
4552
|
+
-- companion picks up and acts on. Calling this endpoint directly will
|
|
4553
|
+
-- always be a no-op telling the caller to use the new path.
|
|
4315
4554
|
-- ============================================
|
|
4316
4555
|
|
|
4317
|
-
-- Track active play test task
|
|
4318
|
-
local activePlayTestTask = nil
|
|
4319
|
-
|
|
4320
4556
|
handlers.playSolo = function(requestData)
|
|
4321
|
-
local
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
end
|
|
4329
|
-
|
|
4330
|
-
-- Try the new StudioTestService first (available since Dec 2025)
|
|
4331
|
-
local studioTestSuccess, studioTestService = pcall(function()
|
|
4332
|
-
return game:GetService("StudioTestService")
|
|
4333
|
-
end)
|
|
4334
|
-
|
|
4335
|
-
if studioTestSuccess and studioTestService then
|
|
4336
|
-
-- Use the new StudioTestService API for proper Play Solo mode
|
|
4337
|
-
-- Run in background task so we don't block the HTTP response
|
|
4338
|
-
activePlayTestTask = task.spawn(function()
|
|
4339
|
-
-- ExecutePlayModeAsync(args: Variant?) - per official docs, pass a string identifier
|
|
4340
|
-
local testResult = studioTestService:ExecutePlayModeAsync("MCP_PlayTest")
|
|
4341
|
-
activePlayTestTask = nil
|
|
4342
|
-
end)
|
|
4557
|
+
local sessionId = requestData and requestData.sessionId
|
|
4558
|
+
if type(sessionId) ~= "string" or sessionId == "" then
|
|
4559
|
+
return {
|
|
4560
|
+
success = false,
|
|
4561
|
+
error = "sessionId required (the bridge generates one and passes it; older clients won't have it).",
|
|
4562
|
+
}
|
|
4563
|
+
end
|
|
4343
4564
|
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
-- Fallback to RunService for older Studio versions
|
|
4353
|
-
RunService:Run()
|
|
4565
|
+
-- Validate StudioTestService is available BEFORE we touch anything.
|
|
4566
|
+
local stsOk, sts = pcall(function() return game:GetService("StudioTestService") end)
|
|
4567
|
+
if not stsOk or not sts then
|
|
4568
|
+
return {
|
|
4569
|
+
success = false,
|
|
4570
|
+
error = "StudioTestService unavailable in this Studio version. Update Roblox Studio to use playtest features.",
|
|
4571
|
+
}
|
|
4572
|
+
end
|
|
4354
4573
|
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4574
|
+
-- HttpService.HttpEnabled preflight.
|
|
4575
|
+
--
|
|
4576
|
+
-- Plugins (this script) bypass HttpEnabled, but in-game scripts — and
|
|
4577
|
+
-- that includes our injected companion running inside the test
|
|
4578
|
+
-- DataModel — do not. Without HttpEnabled the companion can't reach
|
|
4579
|
+
-- the bridge to stream logs or receive the "end" command, so stop_play
|
|
4580
|
+
-- silently times out from the AI's perspective.
|
|
4581
|
+
--
|
|
4582
|
+
-- We can't fix this for the user: HttpEnabled is gated by
|
|
4583
|
+
-- LocalUserSecurity, which means a plugin literally cannot flip it
|
|
4584
|
+
-- from code. Best we can do is fail fast with an actionable error so
|
|
4585
|
+
-- the AI/user knows exactly what to toggle.
|
|
4586
|
+
local httpEnabledOk, httpEnabled = pcall(function()
|
|
4587
|
+
return HttpService.HttpEnabled
|
|
4363
4588
|
end)
|
|
4364
|
-
|
|
4365
|
-
if success then
|
|
4366
|
-
return result
|
|
4367
|
-
else
|
|
4589
|
+
if httpEnabledOk and httpEnabled == false then
|
|
4368
4590
|
return {
|
|
4369
4591
|
success = false,
|
|
4370
|
-
error = "
|
|
4592
|
+
error = "HttpService.HttpEnabled is false. The injected test-session companion runs as an in-game script and needs HTTP access to stream logs and receive the stop command. Enable it in Studio: Game Settings → Security → Allow HTTP Requests (or Home → Game Settings → Security). A plugin cannot toggle this setting itself (LocalUserSecurity).",
|
|
4593
|
+
fixSteps = {
|
|
4594
|
+
"Open Game Settings (Home tab → Game Settings, or File → Game Settings)",
|
|
4595
|
+
"Go to the Security section",
|
|
4596
|
+
"Toggle 'Allow HTTP Requests' ON",
|
|
4597
|
+
"Click Save, then call play_solo again",
|
|
4598
|
+
},
|
|
4371
4599
|
}
|
|
4372
4600
|
end
|
|
4373
|
-
end
|
|
4374
4601
|
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
return game:GetService("StudioTestService")
|
|
4383
|
-
end)
|
|
4602
|
+
-- If a previous test was somehow left running (e.g. plugin reloaded
|
|
4603
|
+
-- mid-test, user clicked Stop without us hearing), best-effort it.
|
|
4604
|
+
-- We can't detect Play mode from a plugin reliably, so we just do an
|
|
4605
|
+
-- orphan sweep — if there's no actual test running, the sweep is a
|
|
4606
|
+
-- no-op; if there is one, the user will need to stop it manually
|
|
4607
|
+
-- since EndTest can only be called from the test's Server DataModel.
|
|
4608
|
+
cleanupTestCompanions()
|
|
4384
4609
|
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4610
|
+
local success, result = pcall(function()
|
|
4611
|
+
local companion = injectTestCompanion(pluginState.serverUrl, sessionId)
|
|
4612
|
+
|
|
4613
|
+
-- ExecutePlayModeAsync yields until the test ends. Run it on its
|
|
4614
|
+
-- own thread so the HTTP response for play_solo returns now,
|
|
4615
|
+
-- rather than after the test concludes. The plugin's poll loop
|
|
4616
|
+
-- continues to service other requests during the test.
|
|
4617
|
+
task.spawn(function()
|
|
4618
|
+
local execOk, execResult = pcall(function()
|
|
4619
|
+
return sts:ExecutePlayModeAsync(sessionId)
|
|
4388
4620
|
end)
|
|
4389
4621
|
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
success = true,
|
|
4394
|
-
method = "StudioTestService:EndTest",
|
|
4395
|
-
message = "Stopped play test"
|
|
4396
|
-
}
|
|
4397
|
-
else
|
|
4398
|
-
-- EndTest failed, try RunService:Stop as fallback
|
|
4399
|
-
RunService:Stop()
|
|
4400
|
-
activePlayTestTask = nil
|
|
4401
|
-
return {
|
|
4402
|
-
success = true,
|
|
4403
|
-
method = "RunService:Stop (fallback)",
|
|
4404
|
-
message = "Stopped play test",
|
|
4405
|
-
warning = "EndTest failed: " .. tostring(endError) .. ". Used RunService:Stop() which does NOT restore pre-play state."
|
|
4406
|
-
}
|
|
4407
|
-
end
|
|
4408
|
-
end
|
|
4622
|
+
-- Test has ended. Always clean up the companion in the Edit
|
|
4623
|
+
-- DataModel (the one in the test DM died with the test).
|
|
4624
|
+
cleanupTestCompanions()
|
|
4409
4625
|
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4626
|
+
-- Tell the bridge the session is over. The companion may have
|
|
4627
|
+
-- already done this if stop was MCP-driven; the bridge's
|
|
4628
|
+
-- endTestSession is idempotent so a duplicate post is fine.
|
|
4629
|
+
pcall(function()
|
|
4630
|
+
HttpService:RequestAsync({
|
|
4631
|
+
Url = pluginState.serverUrl .. "/test-session/ended",
|
|
4632
|
+
Method = "POST",
|
|
4633
|
+
Headers = { ["Content-Type"] = "application/json" },
|
|
4634
|
+
Body = HttpService:JSONEncode({
|
|
4635
|
+
sessionId = sessionId,
|
|
4636
|
+
reason = execOk and "natural" or "exec_error",
|
|
4637
|
+
value = execOk and execResult or nil,
|
|
4638
|
+
error = (not execOk) and tostring(execResult) or nil,
|
|
4639
|
+
}),
|
|
4640
|
+
})
|
|
4641
|
+
end)
|
|
4642
|
+
end)
|
|
4413
4643
|
|
|
4414
4644
|
return {
|
|
4415
4645
|
success = true,
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4646
|
+
sessionId = sessionId,
|
|
4647
|
+
method = "StudioTestService:ExecutePlayModeAsync",
|
|
4648
|
+
companionPath = getInstancePath(companion),
|
|
4649
|
+
message = "Started Play Solo with MCP companion. Use stop_play to end, get_playtest_output to read logs.",
|
|
4419
4650
|
}
|
|
4420
4651
|
end)
|
|
4421
4652
|
|
|
4422
4653
|
if success then
|
|
4423
4654
|
return result
|
|
4424
4655
|
else
|
|
4656
|
+
-- If injection or task spawn fails, make sure we don't leave a
|
|
4657
|
+
-- companion script behind.
|
|
4658
|
+
pcall(cleanupTestCompanions)
|
|
4425
4659
|
return {
|
|
4426
4660
|
success = false,
|
|
4427
|
-
|
|
4661
|
+
sessionId = sessionId,
|
|
4662
|
+
error = "Failed to start play test: " .. tostring(result),
|
|
4428
4663
|
}
|
|
4429
4664
|
end
|
|
4430
4665
|
end
|
|
4431
4666
|
|
|
4667
|
+
handlers.stopPlay = function(requestData)
|
|
4668
|
+
-- Deprecated path. The MCP `stop_play` tool now talks to the bridge
|
|
4669
|
+
-- directly (which queues a command for the in-test companion to pick
|
|
4670
|
+
-- up). This handler exists only so older bridge versions or direct
|
|
4671
|
+
-- HTTP callers don't crash — it never actually stops a test, since
|
|
4672
|
+
-- EndTest cannot be called from the Edit DataModel.
|
|
4673
|
+
return {
|
|
4674
|
+
success = false,
|
|
4675
|
+
deprecated = true,
|
|
4676
|
+
error = "/api/stop-play is deprecated. The MCP stop_play tool now signals the in-test companion via the bridge — update your MCP server to a version that includes the test-session protocol.",
|
|
4677
|
+
}
|
|
4678
|
+
end
|
|
4679
|
+
|
|
4432
4680
|
-- ============================================
|
|
4433
4681
|
-- SCREENSHOT HANDLER
|
|
4434
4682
|
-- ============================================
|
|
@@ -5200,6 +5448,10 @@ local function activatePlugin()
|
|
|
5200
5448
|
screenGui.Enabled = true
|
|
5201
5449
|
updateUIState()
|
|
5202
5450
|
|
|
5451
|
+
-- Sweep any orphan test companions left over from a previous Studio
|
|
5452
|
+
-- session that crashed/was force-closed mid-test. Safe no-op otherwise.
|
|
5453
|
+
pcall(cleanupTestCompanions)
|
|
5454
|
+
|
|
5203
5455
|
pcall(function()
|
|
5204
5456
|
HttpService:RequestAsync({
|
|
5205
5457
|
Url = pluginState.serverUrl .. "/ready",
|
|
@@ -5248,7 +5500,13 @@ local function deactivatePlugin()
|
|
|
5248
5500
|
pluginState.connection:Disconnect()
|
|
5249
5501
|
pluginState.connection = nil
|
|
5250
5502
|
end
|
|
5251
|
-
|
|
5503
|
+
|
|
5504
|
+
-- Don't leave a companion script in the place when the user disables
|
|
5505
|
+
-- the plugin. (If a test is currently running it'll be in the test's
|
|
5506
|
+
-- Server DataModel — that copy dies with the test. We're cleaning the
|
|
5507
|
+
-- Edit-side template so it's not there next time the user saves.)
|
|
5508
|
+
pcall(cleanupTestCompanions)
|
|
5509
|
+
|
|
5252
5510
|
pluginState.consecutiveFailures = 0
|
|
5253
5511
|
pluginState.currentRetryDelay = 0.5
|
|
5254
5512
|
end
|