rbxstudio-mcp 2.2.1 → 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 -91
@@ -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,122 +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
- local wasRunning = RunService:IsRunning()
4378
-
4379
- if not wasRunning then
4380
- return {
4381
- success = true,
4382
- wasRunning = false,
4383
- message = "Play test was not running"
4384
- }
4385
- end
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
4386
4565
 
4387
- -- Try StudioTestService:EndTest first if available (matches ExecutePlayModeAsync)
4388
- local studioTestSuccess, studioTestService = pcall(function()
4389
- return game:GetService("StudioTestService")
4390
- 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()
4391
4573
 
4392
- if studioTestSuccess and studioTestService then
4393
- local endSuccess = pcall(function()
4394
- 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)
4395
4584
  end)
4396
4585
 
4397
- if endSuccess then
4398
- activePlayTestTask = nil
4399
- return {
4400
- success = true,
4401
- wasRunning = true,
4402
- method = "StudioTestService:EndTest",
4403
- message = "Stopped play test (state restored)"
4404
- }
4405
- end
4406
- 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()
4407
4589
 
4408
- -- Fallback to RunService:Stop() if StudioTestService failed
4409
- RunService:Stop()
4410
- 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)
4411
4607
 
4412
4608
  return {
4413
4609
  success = true,
4414
- wasRunning = true,
4415
- method = "RunService:Stop",
4416
- message = "Stopped play test",
4417
- 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.",
4418
4614
  }
4419
4615
  end)
4420
4616
 
4421
4617
  if success then
4422
4618
  return result
4423
4619
  else
4620
+ -- If injection or task spawn fails, make sure we don't leave a
4621
+ -- companion script behind.
4622
+ pcall(cleanupTestCompanions)
4424
4623
  return {
4425
4624
  success = false,
4426
- error = "Failed to stop play test: " .. tostring(result)
4625
+ sessionId = sessionId,
4626
+ error = "Failed to start play test: " .. tostring(result),
4427
4627
  }
4428
4628
  end
4429
4629
  end
4430
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
+
4431
4644
  -- ============================================
4432
4645
  -- SCREENSHOT HANDLER
4433
4646
  -- ============================================
@@ -5199,6 +5412,10 @@ local function activatePlugin()
5199
5412
  screenGui.Enabled = true
5200
5413
  updateUIState()
5201
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
+
5202
5419
  pcall(function()
5203
5420
  HttpService:RequestAsync({
5204
5421
  Url = pluginState.serverUrl .. "/ready",
@@ -5247,7 +5464,13 @@ local function deactivatePlugin()
5247
5464
  pluginState.connection:Disconnect()
5248
5465
  pluginState.connection = nil
5249
5466
  end
5250
-
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
+
5251
5474
  pluginState.consecutiveFailures = 0
5252
5475
  pluginState.currentRetryDelay = 0.5
5253
5476
  end