@chrrxs/robloxstudio-mcp 2.9.0 → 2.9.1

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 CHANGED
@@ -1206,6 +1206,17 @@ function luaLongQuote(s) {
1206
1206
  ${s}
1207
1207
  ]${eq}]`;
1208
1208
  }
1209
+ function parseBridgeResponse(response) {
1210
+ const r = response;
1211
+ if (r && typeof r.returnValue === "string") {
1212
+ try {
1213
+ const parsed = JSON.parse(r.returnValue);
1214
+ return JSON.stringify(parsed);
1215
+ } catch {
1216
+ }
1217
+ }
1218
+ return JSON.stringify(response);
1219
+ }
1209
1220
  var SERVER_LOCAL_NAME, CLIENT_LOCAL_NAME, RobloxStudioTools;
1210
1221
  var init_tools = __esm({
1211
1222
  "../core/dist/tools/index.js"() {
@@ -1755,27 +1766,28 @@ ${code}`
1755
1766
  throw new Error("Code is required for eval_server_runtime");
1756
1767
  }
1757
1768
  const wrapper = `
1769
+ local HttpService = game:GetService("HttpService")
1758
1770
  local bf = game:GetService("ServerScriptService"):FindFirstChild("${SERVER_LOCAL_NAME}")
1759
1771
  if not bf then
1760
- return {
1772
+ return HttpService:JSONEncode({
1761
1773
  bridge = "missing",
1762
1774
  error = "ServerEvalBridge not installed. Bridges are auto-installed at start_playtest and removed at stop_playtest. Start a playtest before calling eval_server_runtime.",
1763
- }
1775
+ })
1764
1776
  end
1765
1777
  local USER_CODE = ${luaLongQuote(code)}
1766
1778
  local ok, result = bf:Invoke(USER_CODE)
1767
- return {
1779
+ return HttpService:JSONEncode({
1768
1780
  bridge = "ok",
1769
1781
  ok = ok,
1770
1782
  result = if result == nil then nil else tostring(result),
1771
- }
1783
+ })
1772
1784
  `;
1773
1785
  const response = await this.client.request("/api/execute-luau", { code: wrapper }, "server");
1774
1786
  return {
1775
1787
  content: [
1776
1788
  {
1777
1789
  type: "text",
1778
- text: JSON.stringify(response)
1790
+ text: parseBridgeResponse(response)
1779
1791
  }
1780
1792
  ]
1781
1793
  };
@@ -1789,12 +1801,13 @@ return {
1789
1801
  throw new Error(`eval_client_runtime requires target=client-N (got: ${clientTarget})`);
1790
1802
  }
1791
1803
  const wrapper = `
1804
+ local HttpService = game:GetService("HttpService")
1792
1805
  local bf = game:GetService("ReplicatedStorage"):FindFirstChild("${CLIENT_LOCAL_NAME}")
1793
1806
  if not bf then
1794
- return {
1807
+ return HttpService:JSONEncode({
1795
1808
  bridge = "missing",
1796
1809
  error = "ClientEvalBridge not installed. Bridges are auto-installed at start_playtest and removed at stop_playtest. Start a playtest before calling eval_client_runtime.",
1797
- }
1810
+ })
1798
1811
  end
1799
1812
  local USER_CODE = ${luaLongQuote(code)}
1800
1813
  local m = Instance.new("ModuleScript")
@@ -1802,23 +1815,23 @@ m.Name = "__MCPEvalPayload"
1802
1815
  local okSet, setErr = pcall(function() m.Source = USER_CODE end)
1803
1816
  if not okSet then
1804
1817
  m:Destroy()
1805
- return { bridge = "ok", ok = false, result = "ModuleScript Source set failed: " .. tostring(setErr) }
1818
+ return HttpService:JSONEncode({ bridge = "ok", ok = false, result = "ModuleScript Source set failed: " .. tostring(setErr) })
1806
1819
  end
1807
1820
  m.Parent = workspace
1808
1821
  local ok, result = bf:Invoke(m)
1809
1822
  m:Destroy()
1810
- return {
1823
+ return HttpService:JSONEncode({
1811
1824
  bridge = "ok",
1812
1825
  ok = ok,
1813
1826
  result = if result == nil then nil else tostring(result),
1814
- }
1827
+ })
1815
1828
  `;
1816
1829
  const response = await this.client.request("/api/execute-luau", { code: wrapper }, clientTarget);
1817
1830
  return {
1818
1831
  content: [
1819
1832
  {
1820
1833
  type: "text",
1821
- text: JSON.stringify(response)
1834
+ text: parseBridgeResponse(response)
1822
1835
  }
1823
1836
  ]
1824
1837
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp",
3
- "version": "2.9.0",
3
+ "version": "2.9.1",
4
4
  "description": "MCP Server for Roblox Studio Integration (fork of boshyxd/robloxstudio-mcp with per-peer execute_luau + stop_playtest fixes)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -796,7 +796,18 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
796
796
  -- DataModel into the play DMs, so the scripts come along and run there.
797
797
  -- TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
798
798
  -- returns (test ended for any reason: stop_playtest, manual close, EndTest).
799
- -- Both scripts have Archivable=false so a user save doesn't persist them.
799
+ --
800
+ -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
801
+ -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
802
+ -- never reached the play DMs because we'd set them to false). We now keep
803
+ -- Archivable=true so the clone works, and rely on cleanupBridges() to
804
+ -- remove the scripts from the edit DM when the test ends. The only failure
805
+ -- mode is the user saving DURING an active playtest, which would persist
806
+ -- the bridges to the .rbxl - that's a no-op next session because
807
+ -- installBridges() always calls cleanupBridges() first to clear stale
808
+ -- instances. The RemoteFunction/BindableFunction that the bridge scripts
809
+ -- CREATE at runtime stay Archivable=false (they're runtime-only and should
810
+ -- never appear in a save).
800
811
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
801
812
  local ServerScriptService = _services.ServerScriptService
802
813
  local StarterPlayer = _services.StarterPlayer
@@ -944,7 +955,9 @@ local function installBridges()
944
955
  local ok, err = pcall(function()
945
956
  local serverScript = Instance.new("Script")
946
957
  serverScript.Name = SERVER_SCRIPT_NAME
947
- serverScript.Archivable = false
958
+ -- Archivable=true so ExecutePlayModeAsync's deep-clone includes the
959
+ -- script. cleanupBridges() removes it from the edit DM when the
960
+ -- playtest ends.
948
961
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
949
962
  serverScript.Parent = ServerScriptService
950
963
  local sps = getStarterPlayerScripts()
@@ -953,7 +966,6 @@ local function installBridges()
953
966
  end
954
967
  local clientScript = Instance.new("LocalScript")
955
968
  clientScript.Name = CLIENT_SCRIPT_NAME
956
- clientScript.Archivable = false
957
969
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
958
970
  clientScript.Parent = sps
959
971
  end)
@@ -3127,13 +3139,56 @@ local function executeLuau(requestData)
3127
3139
  table.insert(output, _arg0)
3128
3140
  oldWarn(unpack(args))
3129
3141
  end
3142
+ -- Try loadstring first (preserves print/warn interception). When
3143
+ -- ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
3144
+ -- peer where the engine respects that gate (notably the play-server DM
3145
+ -- in some Studio configurations), loadstring either returns nil with a
3146
+ -- "loadstring() is not available" message OR throws that same message
3147
+ -- directly. Both paths must trigger the ModuleScript + require
3148
+ -- fallback. The fallback can't intercept print/warn since the
3149
+ -- ModuleScript runs in its own environment, so the output array stays
3150
+ -- empty in that branch - the playtest log buffer already captures
3151
+ -- prints separately via LogService.MessageOut.
3152
+ local runViaModuleScript = function()
3153
+ local m = Instance.new("ModuleScript")
3154
+ m.Name = "__MCPExecLuauPayload"
3155
+ local okSet, setErr = pcall(function()
3156
+ m.Source = code
3157
+ end)
3158
+ if not okSet then
3159
+ m:Destroy()
3160
+ error(`ModuleScript Source set failed: {tostring(setErr)}`)
3161
+ end
3162
+ m.Parent = game:GetService("Workspace")
3163
+ local okReq, reqResult = pcall(function()
3164
+ return require(m)
3165
+ end)
3166
+ m:Destroy()
3167
+ if not okReq then
3168
+ error(tostring(reqResult))
3169
+ end
3170
+ return reqResult
3171
+ end
3172
+ local isLoadstringUnavailable = function(err)
3173
+ local errStr = tostring(err)
3174
+ local matchStart = string.find(errStr, "not available", 1, true)
3175
+ return matchStart ~= nil
3176
+ end
3130
3177
  local success, result = pcall(function()
3131
3178
  local fn, compileError = loadstring(code)
3132
3179
  if not fn then
3180
+ if isLoadstringUnavailable(compileError) then
3181
+ return runViaModuleScript()
3182
+ end
3133
3183
  error(`Compile error: {compileError}`)
3134
3184
  end
3135
3185
  return fn()
3136
3186
  end)
3187
+ -- loadstring throws (not returns nil) in some plugin contexts when
3188
+ -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3189
+ if not success and isLoadstringUnavailable(result) then
3190
+ success, result = pcall(runViaModuleScript)
3191
+ end
3137
3192
  env.print = oldPrint
3138
3193
  env.warn = oldWarn
3139
3194
  if success then
@@ -5579,7 +5634,7 @@ return {
5579
5634
  <Properties>
5580
5635
  <string name="Name">State</string>
5581
5636
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5582
- local CURRENT_VERSION = "2.9.0"
5637
+ local CURRENT_VERSION = "2.9.1"
5583
5638
  local MAX_CONNECTIONS = 5
5584
5639
  local BASE_PORT = 58741
5585
5640
  local activeTabIndex = 0
@@ -796,7 +796,18 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
796
796
  -- DataModel into the play DMs, so the scripts come along and run there.
797
797
  -- TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
798
798
  -- returns (test ended for any reason: stop_playtest, manual close, EndTest).
799
- -- Both scripts have Archivable=false so a user save doesn't persist them.
799
+ --
800
+ -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
801
+ -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
802
+ -- never reached the play DMs because we'd set them to false). We now keep
803
+ -- Archivable=true so the clone works, and rely on cleanupBridges() to
804
+ -- remove the scripts from the edit DM when the test ends. The only failure
805
+ -- mode is the user saving DURING an active playtest, which would persist
806
+ -- the bridges to the .rbxl - that's a no-op next session because
807
+ -- installBridges() always calls cleanupBridges() first to clear stale
808
+ -- instances. The RemoteFunction/BindableFunction that the bridge scripts
809
+ -- CREATE at runtime stay Archivable=false (they're runtime-only and should
810
+ -- never appear in a save).
800
811
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
801
812
  local ServerScriptService = _services.ServerScriptService
802
813
  local StarterPlayer = _services.StarterPlayer
@@ -944,7 +955,9 @@ local function installBridges()
944
955
  local ok, err = pcall(function()
945
956
  local serverScript = Instance.new("Script")
946
957
  serverScript.Name = SERVER_SCRIPT_NAME
947
- serverScript.Archivable = false
958
+ -- Archivable=true so ExecutePlayModeAsync's deep-clone includes the
959
+ -- script. cleanupBridges() removes it from the edit DM when the
960
+ -- playtest ends.
948
961
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
949
962
  serverScript.Parent = ServerScriptService
950
963
  local sps = getStarterPlayerScripts()
@@ -953,7 +966,6 @@ local function installBridges()
953
966
  end
954
967
  local clientScript = Instance.new("LocalScript")
955
968
  clientScript.Name = CLIENT_SCRIPT_NAME
956
- clientScript.Archivable = false
957
969
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
958
970
  clientScript.Parent = sps
959
971
  end)
@@ -3127,13 +3139,56 @@ local function executeLuau(requestData)
3127
3139
  table.insert(output, _arg0)
3128
3140
  oldWarn(unpack(args))
3129
3141
  end
3142
+ -- Try loadstring first (preserves print/warn interception). When
3143
+ -- ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
3144
+ -- peer where the engine respects that gate (notably the play-server DM
3145
+ -- in some Studio configurations), loadstring either returns nil with a
3146
+ -- "loadstring() is not available" message OR throws that same message
3147
+ -- directly. Both paths must trigger the ModuleScript + require
3148
+ -- fallback. The fallback can't intercept print/warn since the
3149
+ -- ModuleScript runs in its own environment, so the output array stays
3150
+ -- empty in that branch - the playtest log buffer already captures
3151
+ -- prints separately via LogService.MessageOut.
3152
+ local runViaModuleScript = function()
3153
+ local m = Instance.new("ModuleScript")
3154
+ m.Name = "__MCPExecLuauPayload"
3155
+ local okSet, setErr = pcall(function()
3156
+ m.Source = code
3157
+ end)
3158
+ if not okSet then
3159
+ m:Destroy()
3160
+ error(`ModuleScript Source set failed: {tostring(setErr)}`)
3161
+ end
3162
+ m.Parent = game:GetService("Workspace")
3163
+ local okReq, reqResult = pcall(function()
3164
+ return require(m)
3165
+ end)
3166
+ m:Destroy()
3167
+ if not okReq then
3168
+ error(tostring(reqResult))
3169
+ end
3170
+ return reqResult
3171
+ end
3172
+ local isLoadstringUnavailable = function(err)
3173
+ local errStr = tostring(err)
3174
+ local matchStart = string.find(errStr, "not available", 1, true)
3175
+ return matchStart ~= nil
3176
+ end
3130
3177
  local success, result = pcall(function()
3131
3178
  local fn, compileError = loadstring(code)
3132
3179
  if not fn then
3180
+ if isLoadstringUnavailable(compileError) then
3181
+ return runViaModuleScript()
3182
+ end
3133
3183
  error(`Compile error: {compileError}`)
3134
3184
  end
3135
3185
  return fn()
3136
3186
  end)
3187
+ -- loadstring throws (not returns nil) in some plugin contexts when
3188
+ -- LoadStringEnabled=false. Catch that here as a second-chance fallback.
3189
+ if not success and isLoadstringUnavailable(result) then
3190
+ success, result = pcall(runViaModuleScript)
3191
+ end
3137
3192
  env.print = oldPrint
3138
3193
  env.warn = oldWarn
3139
3194
  if success then
@@ -5579,7 +5634,7 @@ return {
5579
5634
  <Properties>
5580
5635
  <string name="Name">State</string>
5581
5636
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5582
- local CURRENT_VERSION = "2.9.0"
5637
+ local CURRENT_VERSION = "2.9.1"
5583
5638
  local MAX_CONNECTIONS = 5
5584
5639
  local BASE_PORT = 58741
5585
5640
  local activeTabIndex = 0
@@ -19,7 +19,18 @@
19
19
  // DataModel into the play DMs, so the scripts come along and run there.
20
20
  // TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
21
21
  // returns (test ended for any reason: stop_playtest, manual close, EndTest).
22
- // Both scripts have Archivable=false so a user save doesn't persist them.
22
+ //
23
+ // Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
24
+ // with Archivable=false (verified empirically in v2.9.0 testing - bridges
25
+ // never reached the play DMs because we'd set them to false). We now keep
26
+ // Archivable=true so the clone works, and rely on cleanupBridges() to
27
+ // remove the scripts from the edit DM when the test ends. The only failure
28
+ // mode is the user saving DURING an active playtest, which would persist
29
+ // the bridges to the .rbxl - that's a no-op next session because
30
+ // installBridges() always calls cleanupBridges() first to clear stale
31
+ // instances. The RemoteFunction/BindableFunction that the bridge scripts
32
+ // CREATE at runtime stay Archivable=false (they're runtime-only and should
33
+ // never appear in a save).
23
34
 
24
35
  import { ServerScriptService, StarterPlayer } from "@rbxts/services";
25
36
 
@@ -169,7 +180,9 @@ export function installBridges(): { installed: boolean; error?: string } {
169
180
  const [ok, err] = pcall(() => {
170
181
  const serverScript = new Instance("Script");
171
182
  serverScript.Name = SERVER_SCRIPT_NAME;
172
- serverScript.Archivable = false;
183
+ // Archivable=true so ExecutePlayModeAsync's deep-clone includes the
184
+ // script. cleanupBridges() removes it from the edit DM when the
185
+ // playtest ends.
173
186
  setSource(serverScript, SERVER_BRIDGE_SOURCE);
174
187
  serverScript.Parent = ServerScriptService;
175
188
 
@@ -179,7 +192,6 @@ export function installBridges(): { installed: boolean; error?: string } {
179
192
  }
180
193
  const clientScript = new Instance("LocalScript");
181
194
  clientScript.Name = CLIENT_SCRIPT_NAME;
182
- clientScript.Archivable = false;
183
195
  setSource(clientScript, CLIENT_BRIDGE_SOURCE);
184
196
  clientScript.Parent = sps;
185
197
  });
@@ -281,12 +281,56 @@ function executeLuau(requestData: Record<string, unknown>) {
281
281
  oldWarn(...(args as [defined, ...defined[]]));
282
282
  };
283
283
 
284
- const [success, result] = pcall(() => {
284
+ // Try loadstring first (preserves print/warn interception). When
285
+ // ServerScriptService.LoadStringEnabled=false AND the plugin runs in a
286
+ // peer where the engine respects that gate (notably the play-server DM
287
+ // in some Studio configurations), loadstring either returns nil with a
288
+ // "loadstring() is not available" message OR throws that same message
289
+ // directly. Both paths must trigger the ModuleScript + require
290
+ // fallback. The fallback can't intercept print/warn since the
291
+ // ModuleScript runs in its own environment, so the output array stays
292
+ // empty in that branch - the playtest log buffer already captures
293
+ // prints separately via LogService.MessageOut.
294
+ const runViaModuleScript = () => {
295
+ const m = new Instance("ModuleScript");
296
+ m.Name = "__MCPExecLuauPayload";
297
+ const [okSet, setErr] = pcall(() => {
298
+ (m as unknown as { Source: string }).Source = code;
299
+ });
300
+ if (!okSet) {
301
+ m.Destroy();
302
+ error(`ModuleScript Source set failed: ${tostring(setErr)}`);
303
+ }
304
+ m.Parent = game.GetService("Workspace");
305
+ const [okReq, reqResult] = pcall(() => require(m));
306
+ m.Destroy();
307
+ if (!okReq) error(tostring(reqResult));
308
+ return reqResult;
309
+ };
310
+
311
+ const isLoadstringUnavailable = (err: unknown): boolean => {
312
+ const errStr = tostring(err);
313
+ const [matchStart] = string.find(errStr, "not available", 1, true);
314
+ return matchStart !== undefined;
315
+ };
316
+
317
+ let [success, result] = pcall(() => {
285
318
  const [fn, compileError] = loadstring(code);
286
- if (!fn) error(`Compile error: ${compileError}`);
319
+ if (!fn) {
320
+ if (isLoadstringUnavailable(compileError)) {
321
+ return runViaModuleScript();
322
+ }
323
+ error(`Compile error: ${compileError}`);
324
+ }
287
325
  return fn();
288
326
  });
289
327
 
328
+ // loadstring throws (not returns nil) in some plugin contexts when
329
+ // LoadStringEnabled=false. Catch that here as a second-chance fallback.
330
+ if (!success && isLoadstringUnavailable(result)) {
331
+ [success, result] = pcall(runViaModuleScript);
332
+ }
333
+
290
334
  env["print"] = oldPrint;
291
335
  env["warn"] = oldWarn;
292
336