@chrrxs/robloxstudio-mcp 2.15.2 → 2.16.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.
@@ -5,39 +5,62 @@
5
5
  // `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
6
6
  // shared across every DataModel the plugin runs in (edit DMs, play-server
7
7
  // DMs, play-client DMs). For each connected place we use a dedicated key
8
- // "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
8
+ // "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
9
9
  //
10
- // * The edit DM's stopPlaytest handler writes `true` into its own key
10
+ // * The edit DM's handler writes a tokenized stop request into its own key
11
11
  // (computed from its placeId / ServerStorage anon UUID).
12
12
  // * Each play-server DM's monitor loop polls the key matching its own
13
- // instanceId at 0.1Hz; on `true` it clears the key and calls
14
- // StudioTestService:EndTest. Play-server DMs for other places never
15
- // touch this key.
16
- // * The edit DM waits up to ~8s for its key to be cleared, confirming a
17
- // matching play-server actually consumed the request.
13
+ // instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
14
+ // and writes a matching result token. Play-server DMs for other places
15
+ // never touch this key.
16
+ // * The edit DM waits up to ~8s for its result token, confirming a matching
17
+ // play-server actually consumed the request.
18
18
  //
19
19
  // Earlier versions used a single shared boolean flag, which let any
20
20
  // play-server DM in the same Studio process consume any place's stop
21
21
  // request — silently yanking teammates' playtests. The per-key scoping
22
22
  // below is the fix.
23
23
 
24
- import { HttpService, ServerStorage } from "@rbxts/services";
24
+ import { HttpService, RunService, ServerStorage } from "@rbxts/services";
25
25
 
26
26
  const StudioTestService = game.GetService("StudioTestService");
27
27
 
28
28
  const SETTING_KEY_PREFIX = "MCP_STOP_PLAY_";
29
- // Monitor checks the key at this cadence. 0.1s keeps worst-case detection
30
- // lag tight so the consumption-confirmation window doesn't have to absorb
31
- // polling jitter on top of EndTest's teardown time.
32
- const POLL_INTERVAL_SEC = 0.1;
29
+ // Keep this conservative. plugin:GetSetting is backed by Studio's plugin
30
+ // settings store, and this monitor runs during every play session, including
31
+ // manually-started Play. The official reference implementation polls at 1s.
32
+ const POLL_INTERVAL_SEC = 1;
33
33
  // Total time we wait for the matching play-server DM to consume the
34
34
  // signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
35
35
  // StudioTestService:EndTest teardown (several seconds on heavier places).
36
- // 8s is comfortable; the tighter poll above keeps real cases well under.
36
+ // 8s is intentionally shorter than the MCP request timeout but long enough
37
+ // for the 1s monitor cadence plus ordinary Studio teardown latency.
37
38
  const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
38
39
  const WAIT_POLL_SEC = 0.1;
40
+ const REQUEST_TTL_SEC = 12.0;
39
41
 
40
42
  let pluginRef: Plugin | undefined;
43
+ let endTestIssued = false;
44
+
45
+ interface StopPayload {
46
+ kind?: string;
47
+ id?: string;
48
+ requestedAt?: number;
49
+ consumedAt?: number;
50
+ ok?: boolean;
51
+ error?: string;
52
+ }
53
+
54
+ interface StopRequestResult {
55
+ ok: boolean;
56
+ requestId?: string;
57
+ }
58
+
59
+ interface StopConsumptionResult {
60
+ ok: boolean;
61
+ consumed: boolean;
62
+ error?: string;
63
+ }
41
64
 
42
65
  function init(p: Plugin): void {
43
66
  pluginRef = p;
@@ -65,53 +88,147 @@ function settingKey(instanceId: string): string {
65
88
  return SETTING_KEY_PREFIX + instanceId;
66
89
  }
67
90
 
91
+ function readSetting(key: string): unknown {
92
+ if (!pluginRef) return undefined;
93
+ const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
94
+ return ok ? value : undefined;
95
+ }
96
+
97
+ function writeSetting(key: string, value: unknown): boolean {
98
+ if (!pluginRef) return false;
99
+ const [ok] = pcall(() => pluginRef!.SetSetting(key, value));
100
+ return ok;
101
+ }
102
+
103
+ function decodePayload(value: unknown): StopPayload | undefined {
104
+ let decoded = value;
105
+ if (typeIs(value, "string")) {
106
+ const [ok, result] = pcall(() => HttpService.JSONDecode(value as string));
107
+ if (!ok) return undefined;
108
+ decoded = result;
109
+ }
110
+ if (!typeIs(decoded, "table")) return undefined;
111
+ const payload = decoded as StopPayload;
112
+ if (!typeIs(payload.kind, "string") || !typeIs(payload.id, "string")) {
113
+ return undefined;
114
+ }
115
+ return payload;
116
+ }
117
+
118
+ function writePayload(key: string, payload: StopPayload): boolean {
119
+ const [encodedOk, encoded] = pcall(() => HttpService.JSONEncode(payload));
120
+ if (!encodedOk || !typeIs(encoded, "string")) return false;
121
+ return writeSetting(key, encoded);
122
+ }
123
+
124
+ function writeResult(key: string, request: StopPayload, ok: boolean, errText?: string): void {
125
+ writePayload(key, {
126
+ kind: "result",
127
+ id: request.id,
128
+ requestedAt: request.requestedAt,
129
+ consumedAt: tick(),
130
+ ok,
131
+ error: errText,
132
+ });
133
+ }
134
+
135
+ function handleStopRequest(key: string, request: StopPayload): void {
136
+ if (request.kind !== "request" || !typeIs(request.id, "string")) return;
137
+ if (!typeIs(request.requestedAt, "number")) {
138
+ writeSetting(key, false);
139
+ return;
140
+ }
141
+
142
+ const age = tick() - request.requestedAt;
143
+ if (age < -5 || age > REQUEST_TTL_SEC) {
144
+ writeSetting(key, false);
145
+ return;
146
+ }
147
+
148
+ if (endTestIssued) {
149
+ writeResult(key, request, true);
150
+ return;
151
+ }
152
+
153
+ if (!RunService.IsRunning() || !RunService.IsServer()) {
154
+ writeResult(key, request, false, "StopPlayMonitor is not running in the server DataModel.");
155
+ return;
156
+ }
157
+
158
+ endTestIssued = true;
159
+ const [endOk, endErr] = pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
160
+ writeResult(key, request, endOk, endOk ? undefined : tostring(endErr));
161
+ if (!endOk) {
162
+ endTestIssued = false;
163
+ }
164
+ }
165
+
68
166
  function startMonitor(): void {
69
167
  if (!pluginRef) {
70
- warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping");
168
+ warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping");
71
169
  return;
72
170
  }
73
171
  const myKey = settingKey(computeInstanceId());
74
- // Clear any stale value left from a prior session. If a real stop
75
- // request is in-flight when this runs, the requesting edit DM will
76
- // write again within its consumption-confirmation window.
77
- pcall(() => pluginRef!.SetSetting(myKey, false));
78
172
  task.spawn(() => {
79
173
  while (true) {
80
- const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
81
- if (okGet && val === true) {
82
- // Consume the flag first so requestStop's
83
- // waitForConsumption returns success, then end the test.
84
- pcall(() => pluginRef!.SetSetting(myKey, false));
85
- pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
174
+ const value = readSetting(myKey);
175
+ if (value === true) {
176
+ // Legacy boolean requests are ambiguous and may be stale from
177
+ // a prior crashed session. New stop requests use token payloads.
178
+ writeSetting(myKey, false);
179
+ } else {
180
+ const payload = decodePayload(value);
181
+ if (payload) {
182
+ handleStopRequest(myKey, payload);
183
+ }
86
184
  }
87
185
  task.wait(POLL_INTERVAL_SEC);
88
186
  }
89
187
  });
90
188
  }
91
189
 
92
- function requestStop(): boolean {
93
- if (!pluginRef) return false;
190
+ function requestStop(): StopRequestResult {
191
+ if (!pluginRef) return { ok: false };
94
192
  const myKey = settingKey(computeInstanceId());
95
- const [ok] = pcall(() => pluginRef!.SetSetting(myKey, true));
96
- return ok;
193
+ const requestId = HttpService.GenerateGUID(false);
194
+ const ok = writePayload(myKey, {
195
+ kind: "request",
196
+ id: requestId,
197
+ requestedAt: tick(),
198
+ });
199
+ return { ok, requestId: ok ? requestId : undefined };
97
200
  }
98
201
 
99
- function waitForConsumption(): boolean {
100
- if (!pluginRef) return false;
202
+ function waitForConsumption(requestId: string): StopConsumptionResult {
203
+ if (!pluginRef) return { ok: false, consumed: false, error: "Plugin reference is not initialized." };
101
204
  const myKey = settingKey(computeInstanceId());
102
205
  const start = tick();
103
206
  while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
104
- const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
105
- if (okGet && val !== true) return true;
207
+ const payload = decodePayload(readSetting(myKey));
208
+ if (payload && payload.kind === "result" && payload.id === requestId) {
209
+ return {
210
+ ok: payload.ok === true,
211
+ consumed: true,
212
+ error: payload.error,
213
+ };
214
+ }
106
215
  task.wait(WAIT_POLL_SEC);
107
216
  }
108
- return false;
217
+ return {
218
+ ok: false,
219
+ consumed: false,
220
+ error: "Timed out waiting for the play-server DataModel to acknowledge stop_playtest.",
221
+ };
109
222
  }
110
223
 
111
- function clearPending(): void {
224
+ function clearPending(requestId?: string): void {
112
225
  if (!pluginRef) return;
113
226
  const myKey = settingKey(computeInstanceId());
114
- pcall(() => pluginRef!.SetSetting(myKey, false));
227
+ if (requestId !== undefined) {
228
+ const payload = decodePayload(readSetting(myKey));
229
+ if (payload && payload.id !== requestId) return;
230
+ }
231
+ writeSetting(myKey, false);
115
232
  }
116
233
 
117
234
  export = {
@@ -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) {
@@ -233,16 +231,7 @@ function startPlaytest(requestData: Record<string, unknown>) {
233
231
 
234
232
  const [injected, injErr] = pcall(() => injectStopListener());
235
233
  if (!injected) {
236
- warn(`[MCP] Failed to inject stop listener: ${injErr}`);
237
- }
238
-
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}`);
234
+ warn(`[robloxstudio-mcp] Failed to inject stop listener: ${injErr}`);
246
235
  }
247
236
 
248
237
  task.spawn(() => {
@@ -266,36 +255,26 @@ 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
  }
288
267
 
289
268
  function stopPlaytest(_requestData: Record<string, unknown>) {
290
- // Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
291
- // cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
292
- // calls StudioTestService:EndTest, then resets the flag. We wait up to
293
- // 2.5s for the reset to confirm a play DM actually consumed the request,
294
- // which avoids returning success when nothing is running.
295
- if (!StopPlayMonitor.requestStop()) {
269
+ // Signal the play-server DM's StopPlayMonitor via plugin:SetSetting.
270
+ // The monitor acknowledges with the matching request id only after its
271
+ // StudioTestService:EndTest call returns from pcall.
272
+ const stopRequest = StopPlayMonitor.requestStop();
273
+ if (!stopRequest.ok || stopRequest.requestId === undefined) {
296
274
  return { error: "Plugin not ready. Try again in a moment." };
297
275
  }
298
- if (!StopPlayMonitor.waitForConsumption()) {
276
+ const consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId);
277
+ if (!consumption.ok) {
299
278
  // Two distinct failure modes collapse here, distinguished by whether
300
279
  // THIS edit DM has a playtest tracked:
301
280
  //
@@ -307,19 +286,24 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
307
286
  // from the caller's perspective — playtest may actually have ended).
308
287
  // Tell the caller it's a timing issue and they can retry.
309
288
  //
310
- // Either way clean up the pending flag so a future playtest's monitor
289
+ // Either way clean up the pending request so a future playtest's monitor
311
290
  // doesn't fire EndTest on startup against a stale signal.
312
- StopPlayMonitor.clearPending();
291
+ StopPlayMonitor.clearPending(stopRequest.requestId);
313
292
  if (testRunning) {
314
293
  return {
315
294
  error:
316
- "Playtest stop signal sent but consumption confirmation timed out. " +
295
+ "Playtest stop signal failed or was not acknowledged. " +
317
296
  "The playtest may have ended anyway; check get_connected_instances.",
297
+ detail: consumption.error,
318
298
  };
319
299
  }
320
- return { error: "No active playtest to stop." };
300
+ if (consumption.consumed) {
301
+ return { error: "Playtest stop request reached the play server, but EndTest failed.", detail: consumption.error };
302
+ }
303
+ return { error: "No active playtest to stop.", detail: consumption.error };
321
304
  }
322
- // Flag was consumed (EndTest called). ExecutePlayModeAsync in our
305
+ StopPlayMonitor.clearPending(stopRequest.requestId);
306
+ // Request was consumed (EndTest called). ExecutePlayModeAsync in our
323
307
  // startPlaytest task.spawn is still unwinding though — testRunning stays
324
308
  // true until that yield completes and the post-block runs. Wait so
325
309
  // back-to-back stop -> start sequences don't race against the prior
@@ -370,11 +354,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
370
354
  const testArgs = requestData.testArgs !== undefined ? requestData.testArgs : {};
371
355
  const testId = HttpService.GenerateGUID(false);
372
356
 
373
- const bridgeInstall = installBridges();
374
- if (!bridgeInstall.installed) {
375
- warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
376
- }
377
-
378
357
  multiplayerState = {
379
358
  phase: "starting",
380
359
  testId,
@@ -400,8 +379,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
400
379
  multiplayerState.result = undefined;
401
380
  multiplayerState.error = tostring(result);
402
381
  }
403
-
404
- ensureBridgesInstalled();
405
382
  });
406
383
 
407
384
  const response: Record<string, unknown> = {
@@ -412,9 +389,6 @@ function multiplayerTestStart(requestData: Record<string, unknown>) {
412
389
  numPlayers,
413
390
  testArgs,
414
391
  };
415
- if (!bridgeInstall.installed) {
416
- response.evalBridgesError = bridgeInstall.error;
417
- }
418
392
  return response;
419
393
  }
420
394
 
@@ -2,6 +2,8 @@ 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 ServerUrlSettings from "../modules/ServerUrlSettings";
6
+ import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
5
7
  import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
6
8
  import StopPlayMonitor from "../modules/StopPlayMonitor";
7
9
  import * as RenderMonitor from "../modules/RenderMonitor";
@@ -20,6 +22,7 @@ RuntimeLogBuffer.install();
20
22
  // edit DM (write the flag) and the play-server DM (read+act on the flag) can
21
23
  // access plugin:SetSetting/GetSetting.
22
24
  StopPlayMonitor.init(plugin);
25
+ ServerUrlSettings.init(plugin);
23
26
 
24
27
  UI.init(plugin);
25
28
  const elements = UI.getElements();
@@ -28,10 +31,24 @@ const elements = UI.getElements();
28
31
  const ICON_DISCONNECTED = "rbxassetid://__BUTTON_ICON_DISCONNECTED__";
29
32
  const ICON_CONNECTING = "rbxassetid://__BUTTON_ICON_CONNECTING__";
30
33
  const ICON_CONNECTED = "rbxassetid://__BUTTON_ICON_CONNECTED__";
34
+ const TOOLBAR_REGISTRATION_DELAY_SECONDS = 1;
31
35
 
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 });
36
+ let toolbarButtonRegistered = false;
37
+
38
+ function registerToolbarButton() {
39
+ if (toolbarButtonRegistered) {
40
+ return;
41
+ }
42
+ toolbarButtonRegistered = true;
43
+
44
+ const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
45
+ const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", ICON_DISCONNECTED);
46
+ UI.setToolbarButton(button, { disconnected: ICON_DISCONNECTED, connecting: ICON_CONNECTING, connected: ICON_CONNECTED });
47
+
48
+ button.Click.Connect(() => {
49
+ elements.screenGui.Enabled = !elements.screenGui.Enabled;
50
+ });
51
+ }
35
52
 
36
53
 
37
54
  elements.connectButton.Activated.Connect(() => {
@@ -44,11 +61,6 @@ elements.connectButton.Activated.Connect(() => {
44
61
  });
45
62
 
46
63
 
47
- button.Click.Connect(() => {
48
- elements.screenGui.Enabled = !elements.screenGui.Enabled;
49
- });
50
-
51
-
52
64
  plugin.Unloading.Connect(() => {
53
65
  Communication.deactivateAll();
54
66
  });
@@ -56,6 +68,7 @@ plugin.Unloading.Connect(() => {
56
68
 
57
69
  UI.updateUIState();
58
70
  Communication.checkForUpdates();
71
+ task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton);
59
72
 
60
73
  // Auto-activate per peer. The boshyxd plugin only registers with MCP when the
61
74
  // user clicks Connect in its UI, but that UI is invisible in play DMs - so
@@ -63,15 +76,32 @@ Communication.checkForUpdates();
63
76
  // short delay so the UI/State have a chance to initialize first.
64
77
  task.delay(2, () => {
65
78
  const role = ClientBroker.forkRole();
79
+ if (role === "edit") {
80
+ cleanupLegacyEditBridges();
81
+ } else {
82
+ const result = ensureRuntimeBridgeInstalled();
83
+ if (!result.installed) {
84
+ warn(`[robloxstudio-mcp] Runtime eval bridge install failed: ${result.error}`);
85
+ }
86
+ }
66
87
  if (role === "edit" || role === "server") {
67
88
  pcall(() => {
68
89
  const idx = State.getActiveTabIndex();
69
90
  const conn = State.getConnection(idx);
70
91
  if (conn && !conn.isActive) {
92
+ if (role === "server") {
93
+ const inheritedServerUrl = ServerUrlSettings.readServerUrl() ?? ClientBroker.DEFAULT_MCP_URL;
94
+ conn.serverUrl = inheritedServerUrl;
95
+ elements.urlInput.Text = inheritedServerUrl;
96
+ const [portStr] = conn.serverUrl.match(":(%d+)$");
97
+ if (portStr) conn.port = tonumber(portStr) ?? conn.port;
98
+ ClientBroker.setServerUrl(inheritedServerUrl);
99
+ }
71
100
  // Defensive default: in invisible play-DM UIs, the input field
72
101
  // may not be populated by the time we activate.
73
102
  if (conn.serverUrl === undefined || conn.serverUrl === "") {
74
- conn.serverUrl = ClientBroker.MCP_URL;
103
+ conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
104
+ elements.urlInput.Text = conn.serverUrl;
75
105
  }
76
106
  Communication.activatePlugin(idx);
77
107
  }
@@ -80,8 +110,8 @@ task.delay(2, () => {
80
110
  if (role === "server") {
81
111
  ClientBroker.setupServerBroker();
82
112
  // The play-server DM is the only one where StudioTestService:EndTest is
83
- // legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
84
- // at 1Hz and calls EndTest when the edit DM sets it.
113
+ // legal, so the stop-play monitor lives here. It consumes tokenized
114
+ // stop requests from plugin settings and acknowledges EndTest results.
85
115
  StopPlayMonitor.startMonitor();
86
116
  } else if (role === "client") {
87
117
  ClientBroker.setupClientBroker();