@chrrxs/robloxstudio-mcp-inspector 2.15.2 → 2.16.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.
@@ -21,30 +21,13 @@
21
21
  // ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
22
22
  // when LoadStringEnabled=false (the default in fresh places).
23
23
  //
24
- // Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
25
- // installs them (ensureBridgesInstalled) when the plugin connects in edit,
26
- // and TestHandlers.startPlaytest force-refreshes them right before
27
- // ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
28
- // play DMs, so the scripts come along and run there. We keep them in the edit
29
- // DM after a playtest ends (rather than cleaning up) so that a playtest the
30
- // dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
31
- // tool — also gets the bridges cloned in. This is intentionally a little
32
- // intrusive (two helper scripts visible in Explorer) in exchange for a
33
- // zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
34
- //
35
- // Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
36
- // with Archivable=false (verified empirically in v2.9.0 testing - bridges
37
- // never reached the play DMs because we'd set them to false). We now keep
38
- // Archivable=true so the clone works, and rely on cleanupBridges() to
39
- // remove the scripts from the edit DM when the test ends. The only failure
40
- // mode is the user saving DURING an active playtest, which would persist
41
- // the bridges to the .rbxl - that's a no-op next session because
42
- // installBridges() always calls cleanupBridges() first to clear stale
43
- // instances. The RemoteFunction/BindableFunction that the bridge scripts
44
- // CREATE at runtime stay Archivable=false (they're runtime-only and should
45
- // never appear in a save).
46
-
47
- import { ServerScriptService, StarterPlayer } from "@rbxts/services";
24
+ // Lifecycle: bridge scripts are created only in running play DataModels.
25
+ // The server plugin peer creates the Script in runtime ServerScriptService;
26
+ // each client plugin peer creates its LocalScript in that client's
27
+ // PlayerScripts. Nothing is installed into the edit DataModel anymore.
28
+ // Runtime-created scripts disappear naturally when the playtest stops.
29
+
30
+ import { Players, ReplicatedStorage, RunService, ServerScriptService, StarterPlayer } from "@rbxts/services";
48
31
 
49
32
  const ScriptEditorService = game.GetService("ScriptEditorService");
50
33
 
@@ -122,12 +105,10 @@ end
122
105
  `;
123
106
 
124
107
  // Stamp written onto each installed bridge Script so we can tell whether the
125
- // bridge currently in the DM was produced by THIS plugin build. It's a djb2
126
- // hash of the actual bridge source plus the plugin version, so ANY change to
127
- // the source (or a version bump) yields a new stamp which makes
128
- // ensureBridgesInstalled() force a refresh on the next plugin load instead of
129
- // keeping a stale bridge that happens to still be present (e.g. one saved into
130
- // the .rbxl from an older build).
108
+ // runtime bridge currently in the play DM was produced by THIS plugin build.
109
+ // It's a djb2 hash of the actual bridge source plus the plugin version, so ANY
110
+ // change to the source (or a version bump) yields a new stamp and triggers a
111
+ // runtime refresh instead of keeping a stale bridge.
131
112
  const STAMP_ATTR = "__MCPBridgeStamp";
132
113
 
133
114
  function computeBridgeStamp(): string {
@@ -155,7 +136,12 @@ function setSource(scriptInst: Script | LocalScript, source: string): void {
155
136
  }
156
137
  }
157
138
 
158
- function findBridges(): { server?: Instance; client?: Instance } {
139
+ interface InstallResult {
140
+ installed: boolean;
141
+ error?: string;
142
+ }
143
+
144
+ function findLegacyEditBridges(): { server?: Instance; client?: Instance } {
159
145
  const sps = getStarterPlayerScripts();
160
146
  return {
161
147
  server: ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME),
@@ -163,8 +149,16 @@ function findBridges(): { server?: Instance; client?: Instance } {
163
149
  };
164
150
  }
165
151
 
166
- export function cleanupBridges(): void {
167
- const { server, client } = findBridges();
152
+ function destroyIfPresent(parent: Instance, name: string): void {
153
+ const existing = parent.FindFirstChild(name);
154
+ if (existing) {
155
+ pcall(() => existing.Destroy());
156
+ }
157
+ }
158
+
159
+ export function cleanupLegacyEditBridges(): void {
160
+ if (RunService.IsRunning()) return;
161
+ const { server, client } = findLegacyEditBridges();
168
162
  if (server) {
169
163
  pcall(() => server.Destroy());
170
164
  }
@@ -173,52 +167,75 @@ export function cleanupBridges(): void {
173
167
  }
174
168
  }
175
169
 
176
- // Idempotent variant: install only if the bridge scripts aren't already
177
- // present in the edit DM. Used to keep the bridges always available (so a
178
- // playtest the dev starts manually — not via the MCP start_playtest tool —
179
- // still clones them into the play DMs). Cheap no-op when already installed,
180
- // which avoids re-dirtying the place on every plugin reconnect.
181
- export function ensureBridgesInstalled(): { installed: boolean; error?: string } {
182
- const { server, client } = findBridges();
183
- if (server && client) {
184
- // Both present — but only skip the reinstall if they were produced by
185
- // THIS build. A mismatched/absent stamp means a stale bridge (older
186
- // plugin, or one persisted in the saved place), so force a refresh.
187
- const sStamp = server.GetAttribute(STAMP_ATTR);
188
- const cStamp = client.GetAttribute(STAMP_ATTR);
189
- if (sStamp === BRIDGE_STAMP && cStamp === BRIDGE_STAMP) {
190
- return { installed: true };
191
- }
170
+ function serverRuntimeBridgeReady(): boolean {
171
+ const scriptInst = ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME);
172
+ const bindable = ServerScriptService.FindFirstChild(BRIDGE_NAMES.serverLocal);
173
+ return scriptInst !== undefined &&
174
+ scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
175
+ bindable !== undefined &&
176
+ bindable.IsA("BindableFunction");
177
+ }
178
+
179
+ function getPlayerScripts(): Instance | undefined {
180
+ const localPlayer = Players.LocalPlayer;
181
+ if (!localPlayer) return undefined;
182
+ let playerScripts = localPlayer.FindFirstChild("PlayerScripts");
183
+ if (!playerScripts) {
184
+ playerScripts = localPlayer.WaitForChild("PlayerScripts", 5);
192
185
  }
193
- return installBridges();
186
+ return playerScripts;
187
+ }
188
+
189
+ function clientRuntimeBridgeReady(): boolean {
190
+ const playerScripts = getPlayerScripts();
191
+ if (!playerScripts) return false;
192
+ const scriptInst = playerScripts.FindFirstChild(CLIENT_SCRIPT_NAME);
193
+ const bindable = ReplicatedStorage.FindFirstChild(BRIDGE_NAMES.clientLocal);
194
+ return scriptInst !== undefined &&
195
+ scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
196
+ bindable !== undefined &&
197
+ bindable.IsA("BindableFunction");
194
198
  }
195
199
 
196
- export function installBridges(): { installed: boolean; error?: string } {
197
- // Defensive: clear any stale bridges from a prior unclean exit before
198
- // inserting fresh. The injected script also self-cleans its
199
- // ReplicatedStorage/ServerScriptService children at startup, but the
200
- // containing Script/LocalScript objects themselves we must clear here.
201
- cleanupBridges();
200
+ function installServerRuntimeBridge(): InstallResult {
201
+ if (serverRuntimeBridgeReady()) return { installed: true };
202
202
 
203
203
  const [ok, err] = pcall(() => {
204
+ destroyIfPresent(ServerScriptService, SERVER_SCRIPT_NAME);
205
+ destroyIfPresent(ServerScriptService, BRIDGE_NAMES.serverLocal);
206
+
204
207
  const serverScript = new Instance("Script");
205
208
  serverScript.Name = SERVER_SCRIPT_NAME;
206
- // Archivable=true so ExecutePlayModeAsync's deep-clone includes the
207
- // script. cleanupBridges() removes it from the edit DM when the
208
- // playtest ends.
209
+ serverScript.Archivable = false;
209
210
  setSource(serverScript, SERVER_BRIDGE_SOURCE);
210
211
  serverScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
211
212
  serverScript.Parent = ServerScriptService;
213
+ });
214
+
215
+ if (!ok) {
216
+ return { installed: false, error: tostring(err) };
217
+ }
218
+ return { installed: true };
219
+ }
220
+
221
+ function installClientRuntimeBridge(): InstallResult {
222
+ if (clientRuntimeBridgeReady()) return { installed: true };
223
+
224
+ const playerScripts = getPlayerScripts();
225
+ if (!playerScripts) {
226
+ return { installed: false, error: "Players.LocalPlayer.PlayerScripts not found - cannot install client eval bridge" };
227
+ }
228
+
229
+ const [ok, err] = pcall(() => {
230
+ destroyIfPresent(playerScripts, CLIENT_SCRIPT_NAME);
231
+ destroyIfPresent(ReplicatedStorage, BRIDGE_NAMES.clientLocal);
212
232
 
213
- const sps = getStarterPlayerScripts();
214
- if (!sps) {
215
- error("StarterPlayer.StarterPlayerScripts not found - cannot install client eval bridge");
216
- }
217
233
  const clientScript = new Instance("LocalScript");
218
234
  clientScript.Name = CLIENT_SCRIPT_NAME;
235
+ clientScript.Archivable = false;
219
236
  setSource(clientScript, CLIENT_BRIDGE_SOURCE);
220
237
  clientScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
221
- clientScript.Parent = sps;
238
+ clientScript.Parent = playerScripts;
222
239
  });
223
240
 
224
241
  if (!ok) {
@@ -226,3 +243,13 @@ export function installBridges(): { installed: boolean; error?: string } {
226
243
  }
227
244
  return { installed: true };
228
245
  }
246
+
247
+ export function ensureRuntimeBridgeInstalled(): InstallResult {
248
+ if (!RunService.IsRunning()) {
249
+ return { installed: false, error: "Eval bridges are installed only in running play DataModels" };
250
+ }
251
+ if (RunService.IsServer()) {
252
+ return installServerRuntimeBridge();
253
+ }
254
+ return installClientRuntimeBridge();
255
+ }
@@ -1,5 +1,5 @@
1
1
  import { LogService, ReplicatedStorage, RunService, ServerScriptService } from "@rbxts/services";
2
- import { BRIDGE_NAMES } from "../EvalBridges";
2
+ import { BRIDGE_NAMES, ensureRuntimeBridgeInstalled } from "../EvalBridges";
3
3
  import LuauExec from "../LuauExec";
4
4
 
5
5
  const PAYLOAD_INSTANCE_NAME = "__MCPEvalPayload";
@@ -15,6 +15,21 @@ interface WrapperResult {
15
15
  output?: unknown;
16
16
  }
17
17
 
18
+ function findBridge(config: { service: Instance; bridgeName: string }): BindableFunction | undefined {
19
+ const bridge = config.service.FindFirstChild(config.bridgeName);
20
+ return bridge && bridge.IsA("BindableFunction") ? bridge : undefined;
21
+ }
22
+
23
+ function waitForBridge(config: { service: Instance; bridgeName: string }, timeoutSec = 2): BindableFunction | undefined {
24
+ const deadline = tick() + timeoutSec;
25
+ let bridge = findBridge(config);
26
+ while (!bridge && tick() < deadline) {
27
+ task.wait(0.05);
28
+ bridge = findBridge(config);
29
+ }
30
+ return bridge;
31
+ }
32
+
18
33
  function getBridgeConfig() {
19
34
  if (!RunService.IsRunning()) {
20
35
  return {
@@ -25,13 +40,13 @@ function getBridgeConfig() {
25
40
  return {
26
41
  service: ServerScriptService,
27
42
  bridgeName: BRIDGE_NAMES.serverLocal,
28
- missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
43
+ missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically in the runtime server peer, including for manually-started playtests.",
29
44
  };
30
45
  }
31
46
  return {
32
47
  service: ReplicatedStorage,
33
48
  bridgeName: BRIDGE_NAMES.clientLocal,
34
- missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
49
+ missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically in the runtime client peer, including for manually-started playtests.",
35
50
  };
36
51
  }
37
52
 
@@ -44,9 +59,22 @@ function evalRuntime(requestData: Record<string, unknown>) {
44
59
  return { bridge: "missing", error: config.error };
45
60
  }
46
61
 
47
- const bridge = config.service.FindFirstChild(config.bridgeName);
48
- if (!bridge || !bridge.IsA("BindableFunction")) {
49
- return { bridge: "missing", error: config.missingError };
62
+ let bridge = findBridge(config);
63
+ if (!bridge) {
64
+ const install = ensureRuntimeBridgeInstalled();
65
+ if (!install.installed) {
66
+ return {
67
+ bridge: "missing",
68
+ error: `${config.missingError} Runtime bridge install failed: ${install.error}`,
69
+ };
70
+ }
71
+ bridge = waitForBridge(config);
72
+ }
73
+ if (!bridge) {
74
+ return {
75
+ bridge: "missing",
76
+ error: `${config.missingError} Runtime bridge was installed but did not become ready.`,
77
+ };
50
78
  }
51
79
 
52
80
  const m = new Instance("ModuleScript");
@@ -1,5 +1,4 @@
1
1
  import { HttpService, LogService, Players, RunService } from "@rbxts/services";
2
- import { installBridges, ensureBridgesInstalled } from "../EvalBridges";
3
2
  import StopPlayMonitor from "../StopPlayMonitor";
4
3
 
5
4
  interface StudioTestServiceMultiplayer extends StudioTestService {
@@ -200,9 +199,8 @@ function startPlaytest(requestData: Record<string, unknown>) {
200
199
  logConnection = undefined;
201
200
  }
202
201
  cleanupStopListener();
203
- // Note: eval bridges are intentionally NOT cleaned up they live
204
- // permanently in the edit DM so manual playtests also get them. See
205
- // EvalBridges.ts lifecycle comment.
202
+ // Runtime eval bridges are created by the play server/client plugin
203
+ // peers and disappear with the play DataModels.
206
204
  }
207
205
 
208
206
  if (testRunning) {
@@ -236,15 +234,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
236
234
  warn(`[MCP] Failed to inject stop listener: ${injErr}`);
237
235
  }
238
236
 
239
- // Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
240
- // right before cloning so the play DMs get the current source. They also
241
- // live permanently in the edit DM (installed on connect) so manually-started
242
- // playtests get them too; here we just ensure they're fresh.
243
- const bridgeInstall = installBridges();
244
- if (!bridgeInstall.installed) {
245
- warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
246
- }
247
-
248
237
  task.spawn(() => {
249
238
  const [ok, result] = pcall(() => {
250
239
  if (mode === "play") {
@@ -266,22 +255,12 @@ function startPlaytest(requestData: Record<string, unknown>) {
266
255
  testRunning = false;
267
256
 
268
257
  cleanupStopListener();
269
- // Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
270
- // clean up here, so the next manual playtest still gets them.
271
- ensureBridgesInstalled();
272
258
  });
273
259
 
274
260
  const response: Record<string, unknown> = {
275
261
  success: true,
276
262
  message: `Playtest started in ${mode} mode.`,
277
263
  };
278
- // Only mention eval bridges when they failed — when they're fine, the
279
- // detail is noise. eval_server_runtime / eval_client_runtime will surface
280
- // their own clear errors if the caller tries to use them after a failed
281
- // install.
282
- if (!bridgeInstall.installed) {
283
- response.evalBridgesError = bridgeInstall.error;
284
- }
285
264
 
286
265
  return response;
287
266
  }
@@ -370,11 +349,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
370
349
  const testArgs = requestData.testArgs !== undefined ? requestData.testArgs : {};
371
350
  const testId = HttpService.GenerateGUID(false);
372
351
 
373
- const bridgeInstall = installBridges();
374
- if (!bridgeInstall.installed) {
375
- warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
376
- }
377
-
378
352
  multiplayerState = {
379
353
  phase: "starting",
380
354
  testId,
@@ -400,8 +374,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
400
374
  multiplayerState.result = undefined;
401
375
  multiplayerState.error = tostring(result);
402
376
  }
403
-
404
- ensureBridgesInstalled();
405
377
  });
406
378
 
407
379
  const response: Record<string, unknown> = {
@@ -412,9 +384,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
412
384
  numPlayers,
413
385
  testArgs,
414
386
  };
415
- if (!bridgeInstall.installed) {
416
- response.evalBridgesError = bridgeInstall.error;
417
- }
418
387
  return response;
419
388
  }
420
389
 
@@ -2,6 +2,7 @@ import State from "../modules/State";
2
2
  import UI from "../modules/UI";
3
3
  import Communication from "../modules/Communication";
4
4
  import ClientBroker from "../modules/ClientBroker";
5
+ import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
5
6
  import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
6
7
  import StopPlayMonitor from "../modules/StopPlayMonitor";
7
8
  import * as RenderMonitor from "../modules/RenderMonitor";
@@ -28,10 +29,24 @@ const elements = UI.getElements();
28
29
  const ICON_DISCONNECTED = "rbxassetid://__BUTTON_ICON_DISCONNECTED__";
29
30
  const ICON_CONNECTING = "rbxassetid://__BUTTON_ICON_CONNECTING__";
30
31
  const ICON_CONNECTED = "rbxassetid://__BUTTON_ICON_CONNECTED__";
32
+ const TOOLBAR_REGISTRATION_DELAY_SECONDS = 1;
31
33
 
32
- const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
33
- const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", ICON_DISCONNECTED);
34
- UI.setToolbarButton(button, { disconnected: ICON_DISCONNECTED, connecting: ICON_CONNECTING, connected: ICON_CONNECTED });
34
+ let toolbarButtonRegistered = false;
35
+
36
+ function registerToolbarButton() {
37
+ if (toolbarButtonRegistered) {
38
+ return;
39
+ }
40
+ toolbarButtonRegistered = true;
41
+
42
+ const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
43
+ const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", ICON_DISCONNECTED);
44
+ UI.setToolbarButton(button, { disconnected: ICON_DISCONNECTED, connecting: ICON_CONNECTING, connected: ICON_CONNECTED });
45
+
46
+ button.Click.Connect(() => {
47
+ elements.screenGui.Enabled = !elements.screenGui.Enabled;
48
+ });
49
+ }
35
50
 
36
51
 
37
52
  elements.connectButton.Activated.Connect(() => {
@@ -44,11 +59,6 @@ elements.connectButton.Activated.Connect(() => {
44
59
  });
45
60
 
46
61
 
47
- button.Click.Connect(() => {
48
- elements.screenGui.Enabled = !elements.screenGui.Enabled;
49
- });
50
-
51
-
52
62
  plugin.Unloading.Connect(() => {
53
63
  Communication.deactivateAll();
54
64
  });
@@ -56,6 +66,7 @@ plugin.Unloading.Connect(() => {
56
66
 
57
67
  UI.updateUIState();
58
68
  Communication.checkForUpdates();
69
+ task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton);
59
70
 
60
71
  // Auto-activate per peer. The boshyxd plugin only registers with MCP when the
61
72
  // user clicks Connect in its UI, but that UI is invisible in play DMs - so
@@ -63,6 +74,14 @@ Communication.checkForUpdates();
63
74
  // short delay so the UI/State have a chance to initialize first.
64
75
  task.delay(2, () => {
65
76
  const role = ClientBroker.forkRole();
77
+ if (role === "edit") {
78
+ cleanupLegacyEditBridges();
79
+ } else {
80
+ const result = ensureRuntimeBridgeInstalled();
81
+ if (!result.installed) {
82
+ warn(`[MCPPlugin] Runtime eval bridge install failed: ${result.error}`);
83
+ }
84
+ }
66
85
  if (role === "edit" || role === "server") {
67
86
  pcall(() => {
68
87
  const idx = State.getActiveTabIndex();