@chrrxs/robloxstudio-mcp-inspector 2.8.0 → 2.9.0

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,203 @@
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
+ // Both scripts have Archivable=false so a user save doesn't persist them.
23
+
24
+ import { ServerScriptService, StarterPlayer } from "@rbxts/services";
25
+
26
+ const ScriptEditorService = game.GetService("ScriptEditorService");
27
+
28
+ function getStarterPlayerScripts(): Instance | undefined {
29
+ return StarterPlayer.FindFirstChild("StarterPlayerScripts");
30
+ }
31
+
32
+ const SERVER_SCRIPT_NAME = "__MCP_ServerEvalBridge";
33
+ const CLIENT_SCRIPT_NAME = "__MCP_ClientEvalBridge";
34
+
35
+ // Public so the eval_*_runtime tool wrappers can reference the same names.
36
+ export const BRIDGE_NAMES = {
37
+ serverScript: SERVER_SCRIPT_NAME,
38
+ clientScript: CLIENT_SCRIPT_NAME,
39
+ serverRemote: "__MCP_ServerEvalRemote",
40
+ serverLocal: "__MCP_ServerEvalLocal",
41
+ clientLocal: "__MCP_ClientEvalBridge",
42
+ } as const;
43
+
44
+ // Embedded Luau. The double `${...}` references our exported names so a
45
+ // rename here propagates to both the script source and the tool wrappers.
46
+ const SERVER_BRIDGE_SOURCE = `
47
+ -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at
48
+ -- stop_playtest. Provides shared-require-cache eval on the server peer for
49
+ -- the eval_server_runtime MCP tool.
50
+
51
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
52
+ local ServerScriptService = game:GetService("ServerScriptService")
53
+ local RunService = game:GetService("RunService")
54
+
55
+ if not RunService:IsStudio() then
56
+ return
57
+ end
58
+
59
+ local function evalCode(source)
60
+ if type(source) ~= "string" then
61
+ return false, "source must be a string"
62
+ end
63
+ local fn, compileErr = loadstring(source, "MCPServerEval")
64
+ if not fn then
65
+ local errStr = tostring(compileErr or "loadstring returned nil")
66
+ -- Roblox returns nil from loadstring when LoadStringEnabled=false.
67
+ -- Surface a clear, actionable error.
68
+ if string.find(errStr, "not enabled", 1, true)
69
+ or string.find(errStr, "disabled", 1, true)
70
+ or errStr == "loadstring returned nil"
71
+ then
72
+ return false,
73
+ "ServerScriptService.LoadStringEnabled is false. eval_server_runtime requires it. "
74
+ .. "Enable it in Studio (ServerScriptService > Properties > LoadStringEnabled = true) "
75
+ .. "and restart the playtest."
76
+ end
77
+ return false, errStr
78
+ end
79
+ return pcall(fn)
80
+ end
81
+
82
+ -- Defensive cleanup of stale instances from a prior session.
83
+ local prevRf = ReplicatedStorage:FindFirstChild("${BRIDGE_NAMES.serverRemote}")
84
+ if prevRf then prevRf:Destroy() end
85
+ local prevBf = ServerScriptService:FindFirstChild("${BRIDGE_NAMES.serverLocal}")
86
+ if prevBf then prevBf:Destroy() end
87
+
88
+ local rf = Instance.new("RemoteFunction")
89
+ rf.Name = "${BRIDGE_NAMES.serverRemote}"
90
+ rf.Archivable = false
91
+ rf.Parent = ReplicatedStorage
92
+ rf.OnServerInvoke = function(_player, source)
93
+ return evalCode(source)
94
+ end
95
+
96
+ local bf = Instance.new("BindableFunction")
97
+ bf.Name = "${BRIDGE_NAMES.serverLocal}"
98
+ bf.Archivable = false
99
+ bf.Parent = ServerScriptService
100
+ bf.OnInvoke = function(source)
101
+ return evalCode(source)
102
+ end
103
+ `;
104
+
105
+ const CLIENT_BRIDGE_SOURCE = `
106
+ -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at
107
+ -- stop_playtest. Provides shared-require-cache eval on the client peer for
108
+ -- the eval_client_runtime MCP tool.
109
+
110
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
111
+ local RunService = game:GetService("RunService")
112
+
113
+ if not RunService:IsStudio() then
114
+ return
115
+ end
116
+
117
+ local prevBf = ReplicatedStorage:FindFirstChild("${BRIDGE_NAMES.clientLocal}")
118
+ if prevBf then prevBf:Destroy() end
119
+
120
+ local bf = Instance.new("BindableFunction")
121
+ bf.Name = "${BRIDGE_NAMES.clientLocal}"
122
+ bf.Archivable = false
123
+ bf.Parent = ReplicatedStorage
124
+ bf.OnInvoke = function(payload)
125
+ if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
126
+ return false, "payload must be a ModuleScript instance"
127
+ end
128
+ return pcall(require, payload)
129
+ end
130
+ `;
131
+
132
+ function setSource(scriptInst: Script | LocalScript, source: string): void {
133
+ // ScriptEditorService is the cleaner API and integrates with Studio's
134
+ // edit history; fall back to direct Source mutation (allowed in plugin
135
+ // context with PluginSecurity) if the edit service rejects the call.
136
+ const [seOk] = pcall(() => {
137
+ ScriptEditorService.UpdateSourceAsync(scriptInst, () => source);
138
+ });
139
+ if (!seOk) {
140
+ (scriptInst as unknown as { Source: string }).Source = source;
141
+ }
142
+ }
143
+
144
+ function findBridges(): { server?: Instance; client?: Instance } {
145
+ const sps = getStarterPlayerScripts();
146
+ return {
147
+ server: ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME),
148
+ client: sps ? sps.FindFirstChild(CLIENT_SCRIPT_NAME) : undefined,
149
+ };
150
+ }
151
+
152
+ export function cleanupBridges(): void {
153
+ const { server, client } = findBridges();
154
+ if (server) {
155
+ pcall(() => server.Destroy());
156
+ }
157
+ if (client) {
158
+ pcall(() => client.Destroy());
159
+ }
160
+ }
161
+
162
+ export function installBridges(): { installed: boolean; error?: string } {
163
+ // Defensive: clear any stale bridges from a prior unclean exit before
164
+ // inserting fresh. The injected script also self-cleans its
165
+ // ReplicatedStorage/ServerScriptService children at startup, but the
166
+ // containing Script/LocalScript objects themselves we must clear here.
167
+ cleanupBridges();
168
+
169
+ const [ok, err] = pcall(() => {
170
+ const serverScript = new Instance("Script");
171
+ serverScript.Name = SERVER_SCRIPT_NAME;
172
+ serverScript.Archivable = false;
173
+ setSource(serverScript, SERVER_BRIDGE_SOURCE);
174
+ serverScript.Parent = ServerScriptService;
175
+
176
+ const sps = getStarterPlayerScripts();
177
+ if (!sps) {
178
+ error("StarterPlayer.StarterPlayerScripts not found - cannot install client eval bridge");
179
+ }
180
+ const clientScript = new Instance("LocalScript");
181
+ clientScript.Name = CLIENT_SCRIPT_NAME;
182
+ clientScript.Archivable = false;
183
+ setSource(clientScript, CLIENT_BRIDGE_SOURCE);
184
+ clientScript.Parent = sps;
185
+ });
186
+
187
+ if (!ok) {
188
+ return { installed: false, error: tostring(err) };
189
+ }
190
+ return { installed: true };
191
+ }
192
+
193
+ // Heuristic check so start_playtest can surface a warning when
194
+ // LoadStringEnabled is false (eval_server_runtime won't work in that mode).
195
+ // We can't import the runtime LoadStringEnabled value cleanly without
196
+ // pulling in the type — read defensively.
197
+ export function loadStringEnabled(): boolean {
198
+ const [ok, value] = pcall(
199
+ () => (ServerScriptService as unknown as { LoadStringEnabled: boolean }).LoadStringEnabled,
200
+ );
201
+ return ok && value === true;
202
+ }
203
+
@@ -226,7 +226,9 @@ function init(pluginRef: Plugin) {
226
226
 
227
227
  const screenGui = pluginRef.CreateDockWidgetPluginGuiAsync(
228
228
  "MCPServerInterface",
229
- new DockWidgetPluginGuiInfo(Enum.InitialDockState.Float, false, false, 300, 260, 260, 200),
229
+ // 3rd arg (initialEnabledShouldOverrideRestore=true) forces the dock closed
230
+ // at every Studio launch. User can still open via the toolbar button.
231
+ new DockWidgetPluginGuiInfo(Enum.InitialDockState.Float, false, true, 300, 260, 260, 200),
230
232
  );
231
233
  (screenGui as unknown as { Title: string }).Title = `MCP Server v${CURRENT_VERSION}`;
232
234
 
@@ -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,36 +190,46 @@ 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 };
189
- }
190
199
 
191
- function stopPlaytest(_requestData: Record<string, unknown>) {
192
- // Stop requests are normally intercepted by the server-peer edit-proxy in
193
- // modules/ClientBroker - that proxy runs inside the play server DM, the
194
- // only DM where StudioTestService:EndTest is legal. If we reach this
195
- // handler the broker either hasn't started yet or there's no active
196
- // playtest. Try EndTest directly as a fallback (works for manually
197
- // started playtests where the server-peer plugin happens to be polling).
198
- const endTest = StudioTestService as unknown as Instance & { EndTest(reason: string): void };
199
- const [endOk, endErr] = pcall(() => {
200
- endTest.EndTest("stopped_by_mcp");
201
- });
202
- if (endOk) {
203
- return {
204
- success: true,
205
- output: [...outputBuffer],
206
- outputCount: outputBuffer.size(),
207
- message: "Playtest stopped via StudioTestService.",
208
- };
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.";
209
215
  }
210
216
 
217
+ return response;
218
+ }
219
+
220
+ function stopPlaytest(_requestData: Record<string, unknown>) {
221
+ // Server-side routing (tools/index.ts:stopPlaytest) sends /api/stop-playtest
222
+ // to the role="edit-proxy" instance whenever one is registered. This handler
223
+ // is only reached when there's no edit-proxy - i.e. no active playtest, or
224
+ // the play DMs haven't completed plugin auto-activation yet. Calling
225
+ // StudioTestService:EndTest from the edit DM is illegal ("can only be
226
+ // called from the server DataModel of a running Studio play session"), so
227
+ // don't try - return a clean "no active playtest" response instead.
211
228
  return {
212
- error: `stopPlaytest fell through to edit DM (broker should have handled it). EndTest reported: ${tostring(endErr)}`,
229
+ error: "No active playtest to stop (edit-proxy not registered).",
230
+ hint:
231
+ "If a playtest is running, the play-server DM may not have completed plugin auto-activation yet. " +
232
+ "Wait a moment and retry, or call execute_luau target=server with StudioTestService:EndTest as a manual fallback.",
213
233
  };
214
234
  }
215
235