@chrrxs/robloxstudio-mcp 2.8.1 → 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.
@@ -0,0 +1,215 @@
1
+ // Game-VM eval bridges, ported from chrrxs/roblox-mcp-primitives.
2
+ //
3
+ // Our standard `execute_luau target=server/client-N` runs in the plugin VM
4
+ // with a fresh ModuleScript per call. That gives a clean sandbox but means
5
+ // `require(SomeModule)` returns a fresh copy, not the one the running game
6
+ // scripts hold. So runtime-mutated module state is invisible to probes.
7
+ //
8
+ // These bridges fix that by living inside the user's game scripts:
9
+ // - Server: a Script in ServerScriptService that creates a BindableFunction
10
+ // (for our server-peer plugin to invoke directly) plus a RemoteFunction
11
+ // (kept for parity with the upstream primitive's client-callable shape).
12
+ // - Client: a LocalScript in StarterPlayer.StarterPlayerScripts that
13
+ // creates a BindableFunction. Plugin invokes it with a fresh ModuleScript
14
+ // payload; require() runs inside the LocalScript VM so it shares the
15
+ // game's require cache.
16
+ //
17
+ // Lifecycle: TestHandlers.startPlaytest inserts both scripts into the EDIT
18
+ // DM right before ExecutePlayModeAsync. ExecutePlayModeAsync clones the
19
+ // DataModel into the play DMs, so the scripts come along and run there.
20
+ // TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
21
+ // returns (test ended for any reason: stop_playtest, manual close, EndTest).
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).
34
+
35
+ import { ServerScriptService, StarterPlayer } from "@rbxts/services";
36
+
37
+ const ScriptEditorService = game.GetService("ScriptEditorService");
38
+
39
+ function getStarterPlayerScripts(): Instance | undefined {
40
+ return StarterPlayer.FindFirstChild("StarterPlayerScripts");
41
+ }
42
+
43
+ const SERVER_SCRIPT_NAME = "__MCP_ServerEvalBridge";
44
+ const CLIENT_SCRIPT_NAME = "__MCP_ClientEvalBridge";
45
+
46
+ // Public so the eval_*_runtime tool wrappers can reference the same names.
47
+ export const BRIDGE_NAMES = {
48
+ serverScript: SERVER_SCRIPT_NAME,
49
+ clientScript: CLIENT_SCRIPT_NAME,
50
+ serverRemote: "__MCP_ServerEvalRemote",
51
+ serverLocal: "__MCP_ServerEvalLocal",
52
+ clientLocal: "__MCP_ClientEvalBridge",
53
+ } as const;
54
+
55
+ // Embedded Luau. The double `${...}` references our exported names so a
56
+ // rename here propagates to both the script source and the tool wrappers.
57
+ const SERVER_BRIDGE_SOURCE = `
58
+ -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at
59
+ -- stop_playtest. Provides shared-require-cache eval on the server peer for
60
+ -- the eval_server_runtime MCP tool.
61
+
62
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
63
+ local ServerScriptService = game:GetService("ServerScriptService")
64
+ local RunService = game:GetService("RunService")
65
+
66
+ if not RunService:IsStudio() then
67
+ return
68
+ end
69
+
70
+ local function evalCode(source)
71
+ if type(source) ~= "string" then
72
+ return false, "source must be a string"
73
+ end
74
+ local fn, compileErr = loadstring(source, "MCPServerEval")
75
+ if not fn then
76
+ local errStr = tostring(compileErr or "loadstring returned nil")
77
+ -- Roblox returns nil from loadstring when LoadStringEnabled=false.
78
+ -- Surface a clear, actionable error.
79
+ if string.find(errStr, "not enabled", 1, true)
80
+ or string.find(errStr, "disabled", 1, true)
81
+ or errStr == "loadstring returned nil"
82
+ then
83
+ return false,
84
+ "ServerScriptService.LoadStringEnabled is false. eval_server_runtime requires it. "
85
+ .. "Enable it in Studio (ServerScriptService > Properties > LoadStringEnabled = true) "
86
+ .. "and restart the playtest."
87
+ end
88
+ return false, errStr
89
+ end
90
+ return pcall(fn)
91
+ end
92
+
93
+ -- Defensive cleanup of stale instances from a prior session.
94
+ local prevRf = ReplicatedStorage:FindFirstChild("${BRIDGE_NAMES.serverRemote}")
95
+ if prevRf then prevRf:Destroy() end
96
+ local prevBf = ServerScriptService:FindFirstChild("${BRIDGE_NAMES.serverLocal}")
97
+ if prevBf then prevBf:Destroy() end
98
+
99
+ local rf = Instance.new("RemoteFunction")
100
+ rf.Name = "${BRIDGE_NAMES.serverRemote}"
101
+ rf.Archivable = false
102
+ rf.Parent = ReplicatedStorage
103
+ rf.OnServerInvoke = function(_player, source)
104
+ return evalCode(source)
105
+ end
106
+
107
+ local bf = Instance.new("BindableFunction")
108
+ bf.Name = "${BRIDGE_NAMES.serverLocal}"
109
+ bf.Archivable = false
110
+ bf.Parent = ServerScriptService
111
+ bf.OnInvoke = function(source)
112
+ return evalCode(source)
113
+ end
114
+ `;
115
+
116
+ const CLIENT_BRIDGE_SOURCE = `
117
+ -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at
118
+ -- stop_playtest. Provides shared-require-cache eval on the client peer for
119
+ -- the eval_client_runtime MCP tool.
120
+
121
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
122
+ local RunService = game:GetService("RunService")
123
+
124
+ if not RunService:IsStudio() then
125
+ return
126
+ end
127
+
128
+ local prevBf = ReplicatedStorage:FindFirstChild("${BRIDGE_NAMES.clientLocal}")
129
+ if prevBf then prevBf:Destroy() end
130
+
131
+ local bf = Instance.new("BindableFunction")
132
+ bf.Name = "${BRIDGE_NAMES.clientLocal}"
133
+ bf.Archivable = false
134
+ bf.Parent = ReplicatedStorage
135
+ bf.OnInvoke = function(payload)
136
+ if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
137
+ return false, "payload must be a ModuleScript instance"
138
+ end
139
+ return pcall(require, payload)
140
+ end
141
+ `;
142
+
143
+ function setSource(scriptInst: Script | LocalScript, source: string): void {
144
+ // ScriptEditorService is the cleaner API and integrates with Studio's
145
+ // edit history; fall back to direct Source mutation (allowed in plugin
146
+ // context with PluginSecurity) if the edit service rejects the call.
147
+ const [seOk] = pcall(() => {
148
+ ScriptEditorService.UpdateSourceAsync(scriptInst, () => source);
149
+ });
150
+ if (!seOk) {
151
+ (scriptInst as unknown as { Source: string }).Source = source;
152
+ }
153
+ }
154
+
155
+ function findBridges(): { server?: Instance; client?: Instance } {
156
+ const sps = getStarterPlayerScripts();
157
+ return {
158
+ server: ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME),
159
+ client: sps ? sps.FindFirstChild(CLIENT_SCRIPT_NAME) : undefined,
160
+ };
161
+ }
162
+
163
+ export function cleanupBridges(): void {
164
+ const { server, client } = findBridges();
165
+ if (server) {
166
+ pcall(() => server.Destroy());
167
+ }
168
+ if (client) {
169
+ pcall(() => client.Destroy());
170
+ }
171
+ }
172
+
173
+ export function installBridges(): { installed: boolean; error?: string } {
174
+ // Defensive: clear any stale bridges from a prior unclean exit before
175
+ // inserting fresh. The injected script also self-cleans its
176
+ // ReplicatedStorage/ServerScriptService children at startup, but the
177
+ // containing Script/LocalScript objects themselves we must clear here.
178
+ cleanupBridges();
179
+
180
+ const [ok, err] = pcall(() => {
181
+ const serverScript = new Instance("Script");
182
+ serverScript.Name = SERVER_SCRIPT_NAME;
183
+ // Archivable=true so ExecutePlayModeAsync's deep-clone includes the
184
+ // script. cleanupBridges() removes it from the edit DM when the
185
+ // playtest ends.
186
+ setSource(serverScript, SERVER_BRIDGE_SOURCE);
187
+ serverScript.Parent = ServerScriptService;
188
+
189
+ const sps = getStarterPlayerScripts();
190
+ if (!sps) {
191
+ error("StarterPlayer.StarterPlayerScripts not found - cannot install client eval bridge");
192
+ }
193
+ const clientScript = new Instance("LocalScript");
194
+ clientScript.Name = CLIENT_SCRIPT_NAME;
195
+ setSource(clientScript, CLIENT_BRIDGE_SOURCE);
196
+ clientScript.Parent = sps;
197
+ });
198
+
199
+ if (!ok) {
200
+ return { installed: false, error: tostring(err) };
201
+ }
202
+ return { installed: true };
203
+ }
204
+
205
+ // Heuristic check so start_playtest can surface a warning when
206
+ // LoadStringEnabled is false (eval_server_runtime won't work in that mode).
207
+ // We can't import the runtime LoadStringEnabled value cleanly without
208
+ // pulling in the type — read defensively.
209
+ export function loadStringEnabled(): boolean {
210
+ const [ok, value] = pcall(
211
+ () => (ServerScriptService as unknown as { LoadStringEnabled: boolean }).LoadStringEnabled,
212
+ );
213
+ return ok && value === true;
214
+ }
215
+
@@ -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
 
@@ -1,4 +1,5 @@
1
1
  import { HttpService, LogService } from "@rbxts/services";
2
+ import { installBridges, cleanupBridges, loadStringEnabled } from "../EvalBridges";
2
3
 
3
4
  const StudioTestService = game.GetService("StudioTestService");
4
5
  const ServerScriptService = game.GetService("ServerScriptService");
@@ -154,6 +155,15 @@ function startPlaytest(requestData: Record<string, unknown>) {
154
155
  warn(`[MCP] Failed to inject stop listener: ${injErr}`);
155
156
  }
156
157
 
158
+ // Auto-install the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
159
+ // so eval_server_runtime / eval_client_runtime work without manual setup.
160
+ // Bridges are cleaned up from the edit DM after the play DMs tear down.
161
+ const bridgeInstall = installBridges();
162
+ const hasLoadString = loadStringEnabled();
163
+ if (!bridgeInstall.installed) {
164
+ warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
165
+ }
166
+
157
167
  if (numPlayers !== undefined && mode === "run") {
158
168
  const TestService = game.GetService("TestService") as TestService & { NumberOfPlayers: number };
159
169
  TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8);
@@ -180,12 +190,31 @@ function startPlaytest(requestData: Record<string, unknown>) {
180
190
  testRunning = false;
181
191
 
182
192
  cleanupStopListener();
193
+ cleanupBridges();
183
194
  });
184
195
 
185
196
  const msg = numPlayers !== undefined
186
197
  ? `Playtest started in ${mode} mode with ${numPlayers} player(s)`
187
198
  : `Playtest started in ${mode} mode`;
188
- return { success: true, message: msg };
199
+
200
+ const response: Record<string, unknown> = {
201
+ success: true,
202
+ message: msg,
203
+ evalBridges: bridgeInstall.installed ? "installed" : `failed: ${bridgeInstall.error}`,
204
+ };
205
+
206
+ // Surface loadstring availability up-front so callers know whether
207
+ // eval_server_runtime will work before they try it. eval_client_runtime
208
+ // doesn't need loadstring (it uses ModuleScript+require), so this only
209
+ // affects the server bridge.
210
+ if (!hasLoadString) {
211
+ response.serverEvalNote =
212
+ "ServerScriptService.LoadStringEnabled is false. eval_server_runtime will not work " +
213
+ "until you enable it (ServerScriptService > Properties > LoadStringEnabled = true) " +
214
+ "and restart the playtest. eval_client_runtime is unaffected.";
215
+ }
216
+
217
+ return response;
189
218
  }
190
219
 
191
220
  function stopPlaytest(_requestData: Record<string, unknown>) {