@chrrxs/robloxstudio-mcp 2.11.4 → 2.13.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.
@@ -1,6 +1,45 @@
1
- import { HttpService, Players, ReplicatedStorage, RunService } from "@rbxts/services";
1
+ import { HttpService, Players, ReplicatedStorage, RunService, ServerStorage } from "@rbxts/services";
2
2
  import RuntimeLogBuffer from "./RuntimeLogBuffer";
3
3
  import MemoryHandlers from "./handlers/MemoryHandlers";
4
+ import CaptureHandlers from "./handlers/CaptureHandlers";
5
+ import InputHandlers from "./handlers/InputHandlers";
6
+ import LuauExec from "./LuauExec";
7
+
8
+ // Mirror of Communication.computeInstanceId() — duplicated here because the
9
+ // client broker runs in the play-server DM where it can't easily import from
10
+ // the edit-side module, and the place identifier must match what the edit-DM
11
+ // plugin reports. Both use the same algorithm against the shared DataModel.
12
+ function computeInstanceId(): string {
13
+ if (game.PlaceId !== 0) {
14
+ return `place:${tostring(game.PlaceId)}`;
15
+ }
16
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
17
+ if (typeIs(existing, "string") && existing !== "") {
18
+ return `anon:${existing as string}`;
19
+ }
20
+ const fresh = HttpService.GenerateGUID(false);
21
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
22
+ return `anon:${fresh}`;
23
+ }
24
+
25
+ let cachedPlaceName: string | undefined;
26
+ function resolvePlaceName(): string {
27
+ if (cachedPlaceName !== undefined) return cachedPlaceName;
28
+ if (game.PlaceId === 0) {
29
+ cachedPlaceName = game.Name;
30
+ return cachedPlaceName;
31
+ }
32
+ const MarketplaceService = game.GetService("MarketplaceService");
33
+ const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
34
+ if (ok && info !== undefined) {
35
+ const name = (info as { Name?: string }).Name;
36
+ if (typeIs(name, "string") && name !== "") {
37
+ cachedPlaceName = name;
38
+ return cachedPlaceName;
39
+ }
40
+ }
41
+ return game.Name;
42
+ }
4
43
 
5
44
  // The client peer cannot reach the MCP HTTP server - Roblox forbids
6
45
  // HttpService:RequestAsync from the client DM even under PluginSecurity, and
@@ -18,7 +57,7 @@ const MCP_URL = "http://localhost:58741";
18
57
  const BROKER_NAME = "__MCPClientBroker";
19
58
 
20
59
  interface ProxyEntry {
21
- instanceId: string;
60
+ pluginSessionId: string;
22
61
  role: string;
23
62
  }
24
63
 
@@ -31,12 +70,6 @@ interface BrokerEnvelope {
31
70
  code?: string;
32
71
  }
33
72
 
34
- interface ExecuteResult {
35
- success: boolean;
36
- returnValue?: string;
37
- message?: string;
38
- error?: string;
39
- }
40
73
 
41
74
  // Endpoints the server-peer broker is allowed to forward to the client peer.
42
75
  // Each requires the client peer's plugin VM (because the buffer / require
@@ -45,6 +78,13 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
45
78
  "/api/execute-luau",
46
79
  "/api/get-runtime-logs",
47
80
  "/api/get-memory-breakdown",
81
+ // Screenshot capture must run in the client peer (CaptureService captures
82
+ // the play viewport there); the edit DM reads the temp id back separately.
83
+ "/api/capture-begin",
84
+ // Virtual input (CreateVirtualInput) drives the running client's input
85
+ // pipeline, so it must execute in the client peer's VM.
86
+ "/api/simulate-mouse-input",
87
+ "/api/simulate-keyboard-input",
48
88
  ]);
49
89
 
50
90
  interface ReadyResponseBody {
@@ -72,7 +112,17 @@ function reRegisterProxy(proxyId: string, role: string): void {
72
112
  const last = lastReadyByProxy.get(proxyId) ?? 0;
73
113
  if (now - last < 2) return;
74
114
  lastReadyByProxy.set(proxyId, now);
75
- pcall(() => postJson("/ready", { instanceId: proxyId, role }));
115
+ pcall(() =>
116
+ postJson("/ready", {
117
+ pluginSessionId: proxyId,
118
+ instanceId: computeInstanceId(),
119
+ role,
120
+ placeId: game.PlaceId,
121
+ placeName: resolvePlaceName(),
122
+ dataModelName: game.Name,
123
+ isRunning: RunService.IsRunning(),
124
+ }),
125
+ );
76
126
  }
77
127
 
78
128
  function forkRole(): "edit" | "server" | "client" {
@@ -92,31 +142,16 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
92
142
  );
93
143
  }
94
144
 
95
- function handleExecuteLuau(data: Record<string, unknown> | undefined): ExecuteResult {
145
+ function handleExecuteLuau(data: Record<string, unknown> | undefined) {
96
146
  const code = data && (data.code as string | undefined);
97
147
  if (typeIs(code, "string") === false || code === "") {
98
148
  return { success: false, error: "code is required" };
99
149
  }
100
- const m = new Instance("ModuleScript");
101
- m.Name = "__MCPClientEval";
102
- const [okSet, setErr] = pcall(() => {
103
- (m as unknown as { Source: string }).Source = code as string;
104
- });
105
- if (!okSet) {
106
- m.Destroy();
107
- return { success: false, error: `Source set failed: ${tostring(setErr)}` };
108
- }
109
- m.Parent = game.Workspace;
110
- const [okReq, result] = pcall(() => require(m));
111
- m.Destroy();
112
- if (okReq) {
113
- return {
114
- success: true,
115
- returnValue: result !== undefined ? tostring(result) : undefined,
116
- message: "Code executed successfully",
117
- };
118
- }
119
- return { success: false, error: tostring(result) };
150
+ // Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
151
+ // wrapper (so `print("hi")` with no return doesn't fail the
152
+ // ModuleScript's "must return one value" rule) and JSON-encodes table
153
+ // returns instead of yielding "table: 0xaddr".
154
+ return LuauExec.execute(code as string);
120
155
  }
121
156
 
122
157
  function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknown {
@@ -148,6 +183,15 @@ function setupClientBroker() {
148
183
  if (payload && payload.endpoint === "/api/get-memory-breakdown") {
149
184
  return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
150
185
  }
186
+ if (payload && payload.endpoint === "/api/capture-begin") {
187
+ return CaptureHandlers.captureBegin();
188
+ }
189
+ if (payload && payload.endpoint === "/api/simulate-mouse-input") {
190
+ return InputHandlers.simulateMouseInput(payload.data ?? {});
191
+ }
192
+ if (payload && payload.endpoint === "/api/simulate-keyboard-input") {
193
+ return InputHandlers.simulateKeyboardInput(payload.data ?? {});
194
+ }
151
195
  if (payload && payload.endpoint === "/api/execute-luau") {
152
196
  return handleExecuteLuau(payload.data);
153
197
  }
@@ -162,7 +206,7 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
162
206
  while (player.Parent !== undefined && proxyByPlayer.has(player)) {
163
207
  const [ok, res] = pcall(() =>
164
208
  HttpService.RequestAsync({
165
- Url: `${MCP_URL}/poll?instanceId=${proxyId}`,
209
+ Url: `${MCP_URL}/poll?pluginSessionId=${proxyId}`,
166
210
  Method: "GET",
167
211
  Headers: { "Content-Type": "application/json" },
168
212
  }),
@@ -189,10 +233,12 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
189
233
  response = { success: false, error: `InvokeClient failed: ${tostring(invokeRes)}` };
190
234
  }
191
235
  } else {
236
+ const allowed: string[] = [];
237
+ for (const ep of CLIENT_BROKER_ALLOWED_ENDPOINTS) allowed.push(ep);
192
238
  response = {
193
239
  error:
194
240
  `Client-proxy does not forward ${tostring(request.endpoint)}. ` +
195
- `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
241
+ `Allowed: ${allowed.join(", ")}.`,
196
242
  };
197
243
  }
198
244
  postJson("/response", { requestId: body.requestId, response });
@@ -206,14 +252,22 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
206
252
  function registerProxy(player: Player, rf: RemoteFunction) {
207
253
  if (proxyByPlayer.has(player)) return;
208
254
  const proxyId = HttpService.GenerateGUID(false);
209
- const [ok, res] = postJson("/ready", { instanceId: proxyId, role: "client" });
255
+ const [ok, res] = postJson("/ready", {
256
+ pluginSessionId: proxyId,
257
+ instanceId: computeInstanceId(),
258
+ role: "client",
259
+ placeId: game.PlaceId,
260
+ placeName: resolvePlaceName(),
261
+ dataModelName: game.Name,
262
+ isRunning: RunService.IsRunning(),
263
+ });
210
264
  if (!ok || !res || !res.Success) {
211
265
  warn(`[MCPFork] proxy register failed for ${player.Name}`);
212
266
  return;
213
267
  }
214
268
  const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
215
269
  const assigned = body.assignedRole ?? "client";
216
- proxyByPlayer.set(player, { instanceId: proxyId, role: assigned });
270
+ proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
217
271
  task.spawn(pollProxy, proxyId, player, rf);
218
272
  }
219
273
 
@@ -238,12 +292,12 @@ function setupServerBroker() {
238
292
  const entry = proxyByPlayer.get(p);
239
293
  if (entry) {
240
294
  proxyByPlayer.delete(p);
241
- postJson("/disconnect", { instanceId: entry.instanceId });
295
+ postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
242
296
  }
243
297
  });
244
298
  game.BindToClose(() => {
245
299
  for (const [, entry] of proxyByPlayer) {
246
- postJson("/disconnect", { instanceId: entry.instanceId });
300
+ postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
247
301
  }
248
302
  proxyByPlayer.clear();
249
303
  });
@@ -1,7 +1,8 @@
1
- import { HttpService, RunService } from "@rbxts/services";
1
+ import { HttpService, RunService, ServerStorage } from "@rbxts/services";
2
2
  import State from "./State";
3
3
  import Utils from "./Utils";
4
4
  import UI from "./UI";
5
+ import { ensureBridgesInstalled } from "./EvalBridges";
5
6
  import QueryHandlers from "./handlers/QueryHandlers";
6
7
  import PropertyHandlers from "./handlers/PropertyHandlers";
7
8
  import InstanceHandlers from "./handlers/InstanceHandlers";
@@ -17,8 +18,60 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
17
18
  import MemoryHandlers from "./handlers/MemoryHandlers";
18
19
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
19
20
 
20
- const instanceId = HttpService.GenerateGUID(false);
21
+ // Per-plugin-load random GUID. Used as the /poll URL param so the server
22
+ // can tell our polls apart from any other plugin's polls. Not user-facing —
23
+ // MCP tools and the LLM operate on instanceId (the place identifier).
24
+ const pluginSessionId = HttpService.GenerateGUID(false);
25
+
26
+ // Place-level identifier shared by every plugin running in DataModels of
27
+ // the same place file (edit DM + playtest server DM + playtest clients).
28
+ // Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
29
+ // places where the UUID lives on ServerStorage's __MCPPlaceId attribute
30
+ // and travels with the .rbxl.
31
+ const MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId";
32
+
33
+ function computeInstanceId(): string {
34
+ if (game.PlaceId !== 0) {
35
+ return `place:${tostring(game.PlaceId)}`;
36
+ }
37
+ const existing = ServerStorage.GetAttribute(MCP_PLACE_ID_ATTRIBUTE);
38
+ if (typeIs(existing, "string") && existing !== "") {
39
+ return `anon:${existing as string}`;
40
+ }
41
+ const fresh = HttpService.GenerateGUID(false);
42
+ pcall(() => ServerStorage.SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh));
43
+ return `anon:${fresh}`;
44
+ }
45
+
46
+ const instanceId = computeInstanceId();
21
47
  let assignedRole: string | undefined;
48
+ let duplicateInstanceRole = false;
49
+
50
+ // Cache the published place name from MarketplaceService:GetProductInfo so
51
+ // /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
52
+ // from game.Name (the DataModel name, often "Place1" in edit). We only fetch
53
+ // once per plugin load; the published name doesn't change mid-session.
54
+ let cachedPlaceName: string | undefined;
55
+
56
+ function resolvePlaceName(): string {
57
+ if (cachedPlaceName !== undefined) return cachedPlaceName;
58
+ if (game.PlaceId === 0) {
59
+ cachedPlaceName = game.Name;
60
+ return cachedPlaceName;
61
+ }
62
+ const MarketplaceService = game.GetService("MarketplaceService");
63
+ const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
64
+ if (ok && info !== undefined) {
65
+ const name = (info as { Name?: string }).Name;
66
+ if (typeIs(name, "string") && name !== "") {
67
+ cachedPlaceName = name;
68
+ return cachedPlaceName;
69
+ }
70
+ }
71
+ // Don't cache failures — could be transient (offline, rate-limited).
72
+ // Next /ready will retry. Return game.Name as fallback.
73
+ return game.Name;
74
+ }
22
75
 
23
76
  function detectRole(): string {
24
77
  if (!RunService.IsRunning()) return "edit";
@@ -91,6 +144,8 @@ const routeMap: Record<string, Handler> = {
91
144
  "/api/preview-asset": AssetHandlers.previewAsset,
92
145
 
93
146
  "/api/capture-screenshot": CaptureHandlers.captureScreenshot,
147
+ "/api/capture-begin": CaptureHandlers.captureBegin,
148
+ "/api/capture-read": CaptureHandlers.captureRead,
94
149
  "/api/simulate-mouse-input": InputHandlers.simulateMouseInput,
95
150
  "/api/simulate-keyboard-input": InputHandlers.simulateKeyboardInput,
96
151
 
@@ -140,7 +195,25 @@ function getConnectionStatus(connIndex: number): string {
140
195
  // restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
141
196
  let lastReadyPostAt = 0;
142
197
 
198
+ // game.Name is sometimes "Place1" at plugin-load time and only settles to
199
+ // the real DataModel name (e.g. "Game" once playtest spawns the play DM)
200
+ // after Studio finishes wiring things up. Re-fire /ready when it changes so
201
+ // get_connected_instances doesn't show a stale dataModelName forever. Set
202
+ // up once per plugin load — the connection passed in is whichever was
203
+ // active when activatePlugin was first called.
204
+ let nameChangeConn: RBXScriptConnection | undefined;
205
+ function ensureNameChangeWatcher(conn: Connection): void {
206
+ if (nameChangeConn) return;
207
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
208
+ if (!okSig || !signal) return;
209
+ nameChangeConn = signal.Connect(() => {
210
+ // sendReady has its own 2s throttle, so rapid burst changes coalesce.
211
+ sendReady(conn);
212
+ });
213
+ }
214
+
143
215
  function sendReady(conn: Connection): void {
216
+ if (duplicateInstanceRole) return; // stop retrying once the server has rejected us
144
217
  const now = tick();
145
218
  if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
146
219
  lastReadyPostAt = now;
@@ -151,14 +224,36 @@ function sendReady(conn: Connection): void {
151
224
  Method: "POST",
152
225
  Headers: { "Content-Type": "application/json" },
153
226
  Body: HttpService.JSONEncode({
227
+ pluginSessionId,
154
228
  instanceId,
155
229
  role: detectRole(),
230
+ placeId: game.PlaceId,
231
+ placeName: resolvePlaceName(),
232
+ dataModelName: game.Name,
233
+ isRunning: RunService.IsRunning(),
156
234
  pluginReady: true,
157
235
  timestamp: tick(),
158
236
  }),
159
237
  });
160
238
  });
161
- if (readyOk && readyResult.Success) {
239
+ if (!readyOk) return;
240
+ // 409 = duplicate_instance_role. Surface in UI and stop polling.
241
+ if (readyResult.StatusCode === 409) {
242
+ duplicateInstanceRole = true;
243
+ conn.isActive = false;
244
+ const ui = UI.getElements();
245
+ if (State.getActiveTabIndex() === 0) {
246
+ ui.statusLabel.Text = "Duplicate instance";
247
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
248
+ ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role";
249
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
250
+ }
251
+ warn(
252
+ `[MCPPlugin] Another Studio is already connected as (${instanceId}, ${detectRole()}). Close the other Studio window or this one.`,
253
+ );
254
+ return;
255
+ }
256
+ if (readyResult.Success) {
162
257
  const [parseOk, readyData] = pcall(
163
258
  () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
164
259
  );
@@ -178,7 +273,7 @@ function pollForRequests(connIndex: number) {
178
273
 
179
274
  const [success, result] = pcall(() => {
180
275
  return HttpService.RequestAsync({
181
- Url: `${conn.serverUrl}/poll?instanceId=${instanceId}`,
276
+ Url: `${conn.serverUrl}/poll?pluginSessionId=${pluginSessionId}`,
182
277
  Method: "GET",
183
278
  Headers: { "Content-Type": "application/json" },
184
279
  });
@@ -365,6 +460,24 @@ function activatePlugin(connIndex?: number) {
365
460
  // Initial /ready; pollForRequests will also re-fire ready if the server
366
461
  // later reports knownInstance=false (process restart, etc).
367
462
  sendReady(conn);
463
+
464
+ // Keep the eval bridges present in the edit DM so that ANY playtest —
465
+ // including one the dev starts manually via the Studio Play button —
466
+ // clones them into the play DMs and eval_*_runtime works with no setup
467
+ // roundtrip. Only the edit DM installs; play DMs already have the cloned
468
+ // copies. Idempotent, so reconnects don't re-dirty the place.
469
+ if (!RunService.IsRunning()) {
470
+ task.spawn(() => {
471
+ const result = ensureBridgesInstalled();
472
+ if (!result.installed) {
473
+ warn(`[MCPPlugin] Eval bridge install failed: ${result.error}`);
474
+ }
475
+ });
476
+ }
477
+
478
+ // Watch for game.Name updates so a stale "Place1" captured at first
479
+ // /ready gets refreshed once Studio settles on the real DM name.
480
+ ensureNameChangeWatcher(conn);
368
481
  }
369
482
 
370
483
  function deactivatePlugin(connIndex?: number) {
@@ -383,7 +496,7 @@ function deactivatePlugin(connIndex?: number) {
383
496
  Url: `${conn.serverUrl}/disconnect`,
384
497
  Method: "POST",
385
498
  Headers: { "Content-Type": "application/json" },
386
- Body: HttpService.JSONEncode({ instanceId, timestamp: tick() }),
499
+ Body: HttpService.JSONEncode({ pluginSessionId, timestamp: tick() }),
387
500
  });
388
501
  });
389
502
 
@@ -21,11 +21,16 @@
21
21
  // ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
22
22
  // when LoadStringEnabled=false (the default in fresh places).
23
23
  //
24
- // Lifecycle: TestHandlers.startPlaytest inserts both scripts into the EDIT
25
- // DM right before ExecutePlayModeAsync. ExecutePlayModeAsync clones the
26
- // DataModel into the play DMs, so the scripts come along and run there.
27
- // TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
28
- // returns (test ended for any reason: stop_playtest, manual close, EndTest).
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.
29
34
  //
30
35
  // Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
31
36
  // with Archivable=false (verified empirically in v2.9.0 testing - bridges
@@ -61,9 +66,9 @@ export const BRIDGE_NAMES = {
61
66
  // Embedded Luau. The double `${...}` references our exported names so a
62
67
  // rename here propagates to both the script source and the tool wrappers.
63
68
  const SERVER_BRIDGE_SOURCE = `
64
- -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at
65
- -- stop_playtest. Provides shared-require-cache eval on the server peer for
66
- -- the eval_server_runtime MCP tool.
69
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP
70
+ -- tool (shared-require-cache eval on the server during playtests). Inert
71
+ -- outside Studio (no-ops in live games); safe to leave in place.
67
72
 
68
73
  local ServerScriptService = game:GetService("ServerScriptService")
69
74
  local RunService = game:GetService("RunService")
@@ -88,9 +93,9 @@ end
88
93
  `;
89
94
 
90
95
  const CLIENT_BRIDGE_SOURCE = `
91
- -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at
92
- -- stop_playtest. Provides shared-require-cache eval on the client peer for
93
- -- the eval_client_runtime MCP tool.
96
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP
97
+ -- tool (shared-require-cache eval on the client during playtests). Inert
98
+ -- outside Studio (no-ops in live games); safe to leave in place.
94
99
 
95
100
  local ReplicatedStorage = game:GetService("ReplicatedStorage")
96
101
  local RunService = game:GetService("RunService")
@@ -114,6 +119,28 @@ bf.OnInvoke = function(payload)
114
119
  end
115
120
  `;
116
121
 
122
+ // Stamp written onto each installed bridge Script so we can tell whether the
123
+ // bridge currently in the DM was produced by THIS plugin build. It's a djb2
124
+ // hash of the actual bridge source plus the plugin version, so ANY change to
125
+ // the source (or a version bump) yields a new stamp — which makes
126
+ // ensureBridgesInstalled() force a refresh on the next plugin load instead of
127
+ // keeping a stale bridge that happens to still be present (e.g. one saved into
128
+ // the .rbxl from an older build).
129
+ const STAMP_ATTR = "__MCPBridgeStamp";
130
+
131
+ function computeBridgeStamp(): string {
132
+ const combined = `${SERVER_BRIDGE_SOURCE}|${CLIENT_BRIDGE_SOURCE}`;
133
+ let h = 5381;
134
+ for (let i = 1; i <= combined.size(); i++) {
135
+ h = (h * 33 + string.byte(combined, i)[0]) % 2147483647;
136
+ }
137
+ // "__VERSION__" is replaced with the package version at package time
138
+ // (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
139
+ return `${tostring(h)}-__VERSION__`;
140
+ }
141
+
142
+ const BRIDGE_STAMP = computeBridgeStamp();
143
+
117
144
  function setSource(scriptInst: Script | LocalScript, source: string): void {
118
145
  // ScriptEditorService is the cleaner API and integrates with Studio's
119
146
  // edit history; fall back to direct Source mutation (allowed in plugin
@@ -144,6 +171,26 @@ export function cleanupBridges(): void {
144
171
  }
145
172
  }
146
173
 
174
+ // Idempotent variant: install only if the bridge scripts aren't already
175
+ // present in the edit DM. Used to keep the bridges always available (so a
176
+ // playtest the dev starts manually — not via the MCP start_playtest tool —
177
+ // still clones them into the play DMs). Cheap no-op when already installed,
178
+ // which avoids re-dirtying the place on every plugin reconnect.
179
+ export function ensureBridgesInstalled(): { installed: boolean; error?: string } {
180
+ const { server, client } = findBridges();
181
+ if (server && client) {
182
+ // Both present — but only skip the reinstall if they were produced by
183
+ // THIS build. A mismatched/absent stamp means a stale bridge (older
184
+ // plugin, or one persisted in the saved place), so force a refresh.
185
+ const sStamp = server.GetAttribute(STAMP_ATTR);
186
+ const cStamp = client.GetAttribute(STAMP_ATTR);
187
+ if (sStamp === BRIDGE_STAMP && cStamp === BRIDGE_STAMP) {
188
+ return { installed: true };
189
+ }
190
+ }
191
+ return installBridges();
192
+ }
193
+
147
194
  export function installBridges(): { installed: boolean; error?: string } {
148
195
  // Defensive: clear any stale bridges from a prior unclean exit before
149
196
  // inserting fresh. The injected script also self-cleans its
@@ -158,6 +205,7 @@ export function installBridges(): { installed: boolean; error?: string } {
158
205
  // script. cleanupBridges() removes it from the edit DM when the
159
206
  // playtest ends.
160
207
  setSource(serverScript, SERVER_BRIDGE_SOURCE);
208
+ serverScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
161
209
  serverScript.Parent = ServerScriptService;
162
210
 
163
211
  const sps = getStarterPlayerScripts();
@@ -167,6 +215,7 @@ export function installBridges(): { installed: boolean; error?: string } {
167
215
  const clientScript = new Instance("LocalScript");
168
216
  clientScript.Name = CLIENT_SCRIPT_NAME;
169
217
  setSource(clientScript, CLIENT_BRIDGE_SOURCE);
218
+ clientScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
170
219
  clientScript.Parent = sps;
171
220
  });
172
221