rbxstudio-mcp 2.2.2 → 2.2.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.
Files changed (43) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +0 -0
  3. package/dist/__tests__/bridge-service.test.d.ts +0 -0
  4. package/dist/__tests__/bridge-service.test.d.ts.map +0 -0
  5. package/dist/__tests__/bridge-service.test.js +0 -0
  6. package/dist/__tests__/bridge-service.test.js.map +0 -0
  7. package/dist/__tests__/http-server.test.d.ts +0 -0
  8. package/dist/__tests__/http-server.test.d.ts.map +0 -0
  9. package/dist/__tests__/http-server.test.js +0 -0
  10. package/dist/__tests__/http-server.test.js.map +0 -0
  11. package/dist/__tests__/integration.test.d.ts +0 -0
  12. package/dist/__tests__/integration.test.d.ts.map +0 -0
  13. package/dist/__tests__/integration.test.js +0 -0
  14. package/dist/__tests__/integration.test.js.map +0 -0
  15. package/dist/__tests__/smoke.test.d.ts +0 -0
  16. package/dist/__tests__/smoke.test.d.ts.map +0 -0
  17. package/dist/__tests__/smoke.test.js +0 -0
  18. package/dist/__tests__/smoke.test.js.map +0 -0
  19. package/dist/bridge-service.d.ts +94 -0
  20. package/dist/bridge-service.d.ts.map +1 -1
  21. package/dist/bridge-service.js +188 -1
  22. package/dist/bridge-service.js.map +1 -1
  23. package/dist/http-server.d.ts +0 -0
  24. package/dist/http-server.d.ts.map +1 -1
  25. package/dist/http-server.js +52 -0
  26. package/dist/http-server.js.map +1 -1
  27. package/dist/index.d.ts +0 -0
  28. package/dist/index.d.ts.map +0 -0
  29. package/dist/index.js +27 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/tools/index.d.ts +15 -0
  32. package/dist/tools/index.d.ts.map +1 -1
  33. package/dist/tools/index.js +121 -10
  34. package/dist/tools/index.js.map +1 -1
  35. package/dist/tools/studio-client.d.ts +0 -0
  36. package/dist/tools/studio-client.d.ts.map +0 -0
  37. package/dist/tools/studio-client.js +0 -0
  38. package/dist/tools/studio-client.js.map +0 -0
  39. package/package.json +1 -1
  40. package/studio-plugin/INSTALLATION.md +0 -0
  41. package/studio-plugin/MCPPlugin.rbxmx +0 -0
  42. package/studio-plugin/plugin.json +0 -0
  43. package/studio-plugin/plugin.luau +314 -92
@@ -7,12 +7,232 @@ 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("TestService")
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
+ -- Archivable=false so a stray companion never gets saved into the user's
223
+ -- place file even if cleanup somehow misses it. The play-test mechanism
224
+ -- still includes it in the test DataModel because it duplicates the live
225
+ -- in-memory tree (not the saved file).
226
+ script.Archivable = false
227
+ script.Parent = game:GetService("ServerScriptService")
228
+
229
+ pcall(function()
230
+ CollectionService:AddTag(script, TEST_COMPANION_TAG)
231
+ end)
232
+
233
+ return script
234
+ end
235
+
16
236
  -- Track output log for get_output
17
237
  local outputBuffer = {}
18
238
  local MAX_OUTPUT_BUFFER = 1000
@@ -4312,123 +4532,115 @@ end
4312
4532
 
4313
4533
  -- ============================================
4314
4534
  -- PLAYTEST CONTROL HANDLERS
4535
+ --
4536
+ -- play_solo: receives a sessionId from the bridge, injects a companion
4537
+ -- Script tagged with TEST_COMPANION_TAG into ServerScriptService, then
4538
+ -- spawns ExecutePlayModeAsync in a separate thread so the HTTP response
4539
+ -- can return immediately. When the test ends (for any reason), we sweep
4540
+ -- the companion and notify the bridge.
4541
+ --
4542
+ -- stop_play: this handler is kept only for backward compatibility. The
4543
+ -- real stop path is the bridge enqueueing an "end" command that the
4544
+ -- companion picks up and acts on. Calling this endpoint directly will
4545
+ -- always be a no-op telling the caller to use the new path.
4315
4546
  -- ============================================
4316
4547
 
4317
- -- Track active play test task
4318
- local activePlayTestTask = nil
4319
-
4320
4548
  handlers.playSolo = function(requestData)
4321
- local success, result = pcall(function()
4322
- local wasRunning = RunService:IsRunning()
4323
-
4324
- -- If already running, stop first
4325
- if wasRunning then
4326
- RunService:Stop()
4327
- task.wait(0.2)
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)
4343
-
4344
- return {
4345
- success = true,
4346
- wasRunning = wasRunning,
4347
- method = "StudioTestService:ExecutePlayModeAsync",
4348
- message = wasRunning and "Restarted Play Solo (was already running)" or "Started Play Solo mode",
4349
- note = "Use get_output to read script output. Use stop_play when done."
4350
- }
4351
- else
4352
- -- Fallback to RunService for older Studio versions
4353
- RunService:Run()
4354
-
4355
- return {
4356
- success = true,
4357
- wasRunning = wasRunning,
4358
- method = "RunService:Run (fallback)",
4359
- message = wasRunning and "Restarted play test (was already running)" or "Started Run mode (fallback - StudioTestService not available)",
4360
- note = "Use get_output to read script output. Use stop_play when done."
4361
- }
4362
- end
4363
- end)
4364
-
4365
- if success then
4366
- return result
4367
- else
4549
+ local sessionId = requestData and requestData.sessionId
4550
+ if type(sessionId) ~= "string" or sessionId == "" then
4368
4551
  return {
4369
4552
  success = false,
4370
- error = "Failed to start play test: " .. tostring(result)
4553
+ error = "sessionId required (the bridge generates one and passes it; older clients won't have it).",
4371
4554
  }
4372
4555
  end
4373
- end
4374
4556
 
4375
- handlers.stopPlay = function(requestData)
4376
- local success, result = pcall(function()
4377
- -- NOTE: RunService:IsRunning() returns false from plugin context even when play test is running
4378
- -- So we always try to stop via StudioTestService first
4557
+ -- Validate StudioTestService is available BEFORE we touch anything.
4558
+ local stsOk, sts = pcall(function() return game:GetService("StudioTestService") end)
4559
+ if not stsOk or not sts then
4560
+ return {
4561
+ success = false,
4562
+ error = "StudioTestService unavailable in this Studio version. Update Roblox Studio to use playtest features.",
4563
+ }
4564
+ end
4379
4565
 
4380
- -- Try StudioTestService:EndTest first (matches ExecutePlayModeAsync)
4381
- local studioTestSuccess, studioTestService = pcall(function()
4382
- return game:GetService("StudioTestService")
4383
- end)
4566
+ -- If a previous test was somehow left running (e.g. plugin reloaded
4567
+ -- mid-test, user clicked Stop without us hearing), best-effort it.
4568
+ -- We can't detect Play mode from a plugin reliably, so we just do an
4569
+ -- orphan sweep — if there's no actual test running, the sweep is a
4570
+ -- no-op; if there is one, the user will need to stop it manually
4571
+ -- since EndTest can only be called from the test's Server DataModel.
4572
+ cleanupTestCompanions()
4384
4573
 
4385
- if studioTestSuccess and studioTestService then
4386
- local endSuccess, endError = pcall(function()
4387
- studioTestService:EndTest()
4574
+ local success, result = pcall(function()
4575
+ local companion = injectTestCompanion(pluginState.serverUrl, sessionId)
4576
+
4577
+ -- ExecutePlayModeAsync yields until the test ends. Run it on its
4578
+ -- own thread so the HTTP response for play_solo returns now,
4579
+ -- rather than after the test concludes. The plugin's poll loop
4580
+ -- continues to service other requests during the test.
4581
+ task.spawn(function()
4582
+ local execOk, execResult = pcall(function()
4583
+ return sts:ExecutePlayModeAsync(sessionId)
4388
4584
  end)
4389
4585
 
4390
- if endSuccess then
4391
- activePlayTestTask = nil
4392
- return {
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
4586
+ -- Test has ended. Always clean up the companion in the Edit
4587
+ -- DataModel (the one in the test DM died with the test).
4588
+ cleanupTestCompanions()
4409
4589
 
4410
- -- No StudioTestService, use RunService:Stop
4411
- RunService:Stop()
4412
- activePlayTestTask = nil
4590
+ -- Tell the bridge the session is over. The companion may have
4591
+ -- already done this if stop was MCP-driven; the bridge's
4592
+ -- endTestSession is idempotent so a duplicate post is fine.
4593
+ pcall(function()
4594
+ HttpService:RequestAsync({
4595
+ Url = pluginState.serverUrl .. "/test-session/ended",
4596
+ Method = "POST",
4597
+ Headers = { ["Content-Type"] = "application/json" },
4598
+ Body = HttpService:JSONEncode({
4599
+ sessionId = sessionId,
4600
+ reason = execOk and "natural" or "exec_error",
4601
+ value = execOk and execResult or nil,
4602
+ error = (not execOk) and tostring(execResult) or nil,
4603
+ }),
4604
+ })
4605
+ end)
4606
+ end)
4413
4607
 
4414
4608
  return {
4415
4609
  success = true,
4416
- method = "RunService:Stop",
4417
- message = "Stopped play test",
4418
- warning = "Note: RunService:Stop() does NOT restore pre-play state. Objects created/modified during play remain changed."
4610
+ sessionId = sessionId,
4611
+ method = "StudioTestService:ExecutePlayModeAsync",
4612
+ companionPath = getInstancePath(companion),
4613
+ message = "Started Play Solo with MCP companion. Use stop_play to end, get_playtest_output to read logs.",
4419
4614
  }
4420
4615
  end)
4421
4616
 
4422
4617
  if success then
4423
4618
  return result
4424
4619
  else
4620
+ -- If injection or task spawn fails, make sure we don't leave a
4621
+ -- companion script behind.
4622
+ pcall(cleanupTestCompanions)
4425
4623
  return {
4426
4624
  success = false,
4427
- error = "Failed to stop play test: " .. tostring(result)
4625
+ sessionId = sessionId,
4626
+ error = "Failed to start play test: " .. tostring(result),
4428
4627
  }
4429
4628
  end
4430
4629
  end
4431
4630
 
4631
+ handlers.stopPlay = function(requestData)
4632
+ -- Deprecated path. The MCP `stop_play` tool now talks to the bridge
4633
+ -- directly (which queues a command for the in-test companion to pick
4634
+ -- up). This handler exists only so older bridge versions or direct
4635
+ -- HTTP callers don't crash — it never actually stops a test, since
4636
+ -- EndTest cannot be called from the Edit DataModel.
4637
+ return {
4638
+ success = false,
4639
+ deprecated = true,
4640
+ 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.",
4641
+ }
4642
+ end
4643
+
4432
4644
  -- ============================================
4433
4645
  -- SCREENSHOT HANDLER
4434
4646
  -- ============================================
@@ -5200,6 +5412,10 @@ local function activatePlugin()
5200
5412
  screenGui.Enabled = true
5201
5413
  updateUIState()
5202
5414
 
5415
+ -- Sweep any orphan test companions left over from a previous Studio
5416
+ -- session that crashed/was force-closed mid-test. Safe no-op otherwise.
5417
+ pcall(cleanupTestCompanions)
5418
+
5203
5419
  pcall(function()
5204
5420
  HttpService:RequestAsync({
5205
5421
  Url = pluginState.serverUrl .. "/ready",
@@ -5248,7 +5464,13 @@ local function deactivatePlugin()
5248
5464
  pluginState.connection:Disconnect()
5249
5465
  pluginState.connection = nil
5250
5466
  end
5251
-
5467
+
5468
+ -- Don't leave a companion script in the place when the user disables
5469
+ -- the plugin. (If a test is currently running it'll be in the test's
5470
+ -- Server DataModel — that copy dies with the test. We're cleaning the
5471
+ -- Edit-side template so it's not there next time the user saves.)
5472
+ pcall(cleanupTestCompanions)
5473
+
5252
5474
  pluginState.consecutiveFailures = 0
5253
5475
  pluginState.currentRetryDelay = 0.5
5254
5476
  end