@chrrxs/robloxstudio-mcp 2.16.1 → 2.16.3

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.
@@ -18,6 +18,7 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
18
18
  import MemoryHandlers from "./handlers/MemoryHandlers";
19
19
  import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
20
20
  import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
21
+ import ClientBroker from "./ClientBroker";
21
22
  import ServerUrlSettings from "./ServerUrlSettings";
22
23
  import HttpDiagnostics from "./HttpDiagnostics";
23
24
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
@@ -47,11 +48,11 @@ function computeInstanceId(): string {
47
48
  return `anon:${fresh}`;
48
49
  }
49
50
 
50
- const instanceId = computeInstanceId();
51
51
  let assignedRole: string | undefined;
52
52
  let duplicateInstanceRole = false;
53
53
  let hasVersionMismatch = false;
54
54
  let lastVersionMismatchWarningKey: string | undefined;
55
+ let lastReadyInstanceId: string | undefined;
55
56
  const readyFailureLogKeys = new Set<string>();
56
57
 
57
58
  // Cache the published place name from MarketplaceService:GetProductInfo so
@@ -59,9 +60,12 @@ const readyFailureLogKeys = new Set<string>();
59
60
  // from game.Name (the DataModel name, often "Place1" in edit). We only fetch
60
61
  // once per plugin load; the published name doesn't change mid-session.
61
62
  let cachedPlaceName: string | undefined;
63
+ let cachedPlaceNamePlaceId: number | undefined;
62
64
 
63
65
  function resolvePlaceName(): string {
64
- if (cachedPlaceName !== undefined) return cachedPlaceName;
66
+ if (cachedPlaceName !== undefined && cachedPlaceNamePlaceId === game.PlaceId) return cachedPlaceName;
67
+ cachedPlaceName = undefined;
68
+ cachedPlaceNamePlaceId = game.PlaceId;
65
69
  if (game.PlaceId === 0) {
66
70
  cachedPlaceName = game.Name;
67
71
  return cachedPlaceName;
@@ -86,6 +90,8 @@ function detectRole(): string {
86
90
  return "client";
87
91
  }
88
92
 
93
+ const initialRole = detectRole();
94
+
89
95
  type Handler = (data: Record<string, unknown>) => unknown;
90
96
 
91
97
  const routeMap: Record<string, Handler> = {
@@ -209,21 +215,31 @@ function getConnectionStatus(connIndex: number): string {
209
215
  // restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
210
216
  let lastReadyPostAt = 0;
211
217
 
212
- // game.Name is sometimes "Place1" at plugin-load time and only settles to
213
- // the real DataModel name (e.g. "Game" once playtest spawns the play DM)
214
- // after Studio finishes wiring things up. Re-fire /ready when it changes so
215
- // get_connected_instances doesn't show a stale dataModelName forever. Set
216
- // up once per plugin load — the connection passed in is whichever was
217
- // active when activatePlugin was first called.
218
+ // game.Name and game.PlaceId can both settle after plugin load. PlaceId also
219
+ // changes when an unpublished file is published while MCP is already active.
220
+ // Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
218
221
  let nameChangeConn: RBXScriptConnection | undefined;
219
- function ensureNameChangeWatcher(conn: Connection): void {
220
- if (nameChangeConn) return;
221
- const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
222
- if (!okSig || !signal) return;
223
- nameChangeConn = signal.Connect(() => {
224
- // sendReady has its own 2s throttle, so rapid burst changes coalesce.
225
- sendReady(conn);
226
- });
222
+ let placeIdChangeConn: RBXScriptConnection | undefined;
223
+ function ensureIdentityWatcher(conn: Connection): void {
224
+ if (!nameChangeConn) {
225
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
226
+ if (okSig && signal) {
227
+ nameChangeConn = signal.Connect(() => {
228
+ // sendReady has its own 2s throttle, so rapid burst changes coalesce.
229
+ sendReady(conn);
230
+ });
231
+ }
232
+ }
233
+ if (!placeIdChangeConn) {
234
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("PlaceId"));
235
+ if (okSig && signal) {
236
+ placeIdChangeConn = signal.Connect(() => {
237
+ cachedPlaceName = undefined;
238
+ cachedPlaceNamePlaceId = undefined;
239
+ sendReady(conn);
240
+ });
241
+ }
242
+ }
227
243
  }
228
244
 
229
245
  function sendReady(conn: Connection): void {
@@ -231,6 +247,7 @@ function sendReady(conn: Connection): void {
231
247
  const now = tick();
232
248
  if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
233
249
  lastReadyPostAt = now;
250
+ const instanceId = computeInstanceId();
234
251
  task.spawn(() => {
235
252
  const [readyOk, readyResult] = pcall(() => {
236
253
  return HttpService.RequestAsync({
@@ -286,6 +303,9 @@ function sendReady(conn: Connection): void {
286
303
  if (parseOk && readyData.assignedRole) {
287
304
  assignedRole = readyData.assignedRole;
288
305
  }
306
+ lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
307
+ ? readyData.instanceId
308
+ : instanceId;
289
309
  const connectedRole = assignedRole ?? detectRole();
290
310
  if (readyFailureLogKeys.has(readyLogKey)) {
291
311
  readyFailureLogKeys.delete(readyLogKey);
@@ -493,6 +513,17 @@ function activatePlugin(connIndex?: number) {
493
513
  if (!conn.heartbeatConnection) {
494
514
  conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
495
515
  const now = tick();
516
+ if (initialRole === "server" && !RunService.IsRunning()) {
517
+ ClientBroker.disconnectAllProxies();
518
+ deactivatePlugin(idx);
519
+ return;
520
+ }
521
+ const currentInstanceId = computeInstanceId();
522
+ if (lastReadyInstanceId !== undefined && currentInstanceId !== lastReadyInstanceId) {
523
+ cachedPlaceName = undefined;
524
+ cachedPlaceNamePlaceId = undefined;
525
+ sendReady(conn);
526
+ }
496
527
  const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
497
528
  if (now - conn.lastPoll > currentInterval) {
498
529
  conn.lastPoll = now;
@@ -511,9 +542,8 @@ function activatePlugin(connIndex?: number) {
511
542
  task.spawn(cleanupLegacyEditBridges);
512
543
  }
513
544
 
514
- // Watch for game.Name updates so a stale "Place1" captured at first
515
- // /ready gets refreshed once Studio settles on the real DM name.
516
- ensureNameChangeWatcher(conn);
545
+ // Watch identity fields so stale name or anon instance ids are refreshed.
546
+ ensureIdentityWatcher(conn);
517
547
  }
518
548
 
519
549
  function deactivatePlugin(connIndex?: number) {
@@ -8,17 +8,26 @@ function init(p: Plugin): void {
8
8
  pluginRef = p;
9
9
  }
10
10
 
11
- function computeInstanceId(): string {
11
+ function addUnique(values: string[], value: string): void {
12
+ if (!values.includes(value)) {
13
+ values.push(value);
14
+ }
15
+ }
16
+
17
+ function computeInstanceIds(): string[] {
18
+ const ids: string[] = [];
12
19
  if (game.PlaceId !== 0) {
13
- return `place:${tostring(game.PlaceId)}`;
20
+ addUnique(ids, `place:${tostring(game.PlaceId)}`);
14
21
  }
15
22
  const existing = ServerStorage.GetAttribute("__MCPPlaceId");
16
23
  if (typeIs(existing, "string") && existing !== "") {
17
- return `anon:${existing as string}`;
24
+ addUnique(ids, `anon:${existing as string}`);
25
+ } else if (game.PlaceId === 0) {
26
+ const fresh = HttpService.GenerateGUID(false);
27
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
28
+ addUnique(ids, `anon:${fresh}`);
18
29
  }
19
- const fresh = HttpService.GenerateGUID(false);
20
- pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
21
- return `anon:${fresh}`;
30
+ return ids;
22
31
  }
23
32
 
24
33
  function settingKey(instanceId: string): string {
@@ -27,16 +36,20 @@ function settingKey(instanceId: string): string {
27
36
 
28
37
  function rememberServerUrl(serverUrl: string): void {
29
38
  if (!pluginRef || serverUrl === "") return;
30
- const key = settingKey(computeInstanceId());
31
- pcall(() => pluginRef!.SetSetting(key, serverUrl));
39
+ for (const instanceId of computeInstanceIds()) {
40
+ const key = settingKey(instanceId);
41
+ pcall(() => pluginRef!.SetSetting(key, serverUrl));
42
+ }
32
43
  }
33
44
 
34
45
  function readServerUrl(): string | undefined {
35
46
  if (!pluginRef) return undefined;
36
- const key = settingKey(computeInstanceId());
37
- const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
38
- if (ok && typeIs(value, "string") && value !== "") {
39
- return value as string;
47
+ for (const instanceId of computeInstanceIds()) {
48
+ const key = settingKey(instanceId);
49
+ const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
50
+ if (ok && typeIs(value, "string") && value !== "") {
51
+ return value as string;
52
+ }
40
53
  }
41
54
  return undefined;
42
55
  }
@@ -1,6 +1,9 @@
1
1
  // Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
2
2
  // per-instance setting key so the same Studio process can host playtests
3
3
  // for multiple places without one place's stop_playtest yanking another's.
4
+ // During publish-after-connect, both "anon:<uuid>" and "place:<PlaceId>"
5
+ // can refer to the same Studio place, so stop requests are mirrored across
6
+ // both keys while the monitor waits for a matching result on either key.
4
7
  //
5
8
  // `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
6
9
  // shared across every DataModel the plugin runs in (edit DMs, play-server
@@ -71,23 +74,36 @@ function init(p: Plugin): void {
71
74
  // agree on the place identifier (published places: placeId; unpublished:
72
75
  // UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
73
76
  // into the play DM).
74
- function computeInstanceId(): string {
77
+ function addUnique(values: string[], value: string): void {
78
+ if (!values.includes(value)) {
79
+ values.push(value);
80
+ }
81
+ }
82
+
83
+ function computeInstanceIds(): string[] {
84
+ const ids: string[] = [];
75
85
  if (game.PlaceId !== 0) {
76
- return `place:${tostring(game.PlaceId)}`;
86
+ addUnique(ids, `place:${tostring(game.PlaceId)}`);
77
87
  }
78
88
  const existing = ServerStorage.GetAttribute("__MCPPlaceId");
79
89
  if (typeIs(existing, "string") && existing !== "") {
80
- return `anon:${existing as string}`;
90
+ addUnique(ids, `anon:${existing as string}`);
91
+ } else if (game.PlaceId === 0) {
92
+ const fresh = HttpService.GenerateGUID(false);
93
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
94
+ addUnique(ids, `anon:${fresh}`);
81
95
  }
82
- const fresh = HttpService.GenerateGUID(false);
83
- pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
84
- return `anon:${fresh}`;
96
+ return ids;
85
97
  }
86
98
 
87
99
  function settingKey(instanceId: string): string {
88
100
  return SETTING_KEY_PREFIX + instanceId;
89
101
  }
90
102
 
103
+ function settingKeys(): string[] {
104
+ return computeInstanceIds().map((instanceId) => settingKey(instanceId));
105
+ }
106
+
91
107
  function readSetting(key: string): unknown {
92
108
  if (!pluginRef) return undefined;
93
109
  const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
@@ -146,7 +162,12 @@ function handleStopRequest(key: string, request: StopPayload): void {
146
162
  }
147
163
 
148
164
  if (endTestIssued) {
149
- writeResult(key, request, true);
165
+ writeResult(
166
+ key,
167
+ request,
168
+ false,
169
+ "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.",
170
+ );
150
171
  return;
151
172
  }
152
173
 
@@ -168,18 +189,19 @@ function startMonitor(): void {
168
189
  warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping");
169
190
  return;
170
191
  }
171
- const myKey = settingKey(computeInstanceId());
172
192
  task.spawn(() => {
173
193
  while (true) {
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);
194
+ for (const myKey of settingKeys()) {
195
+ const value = readSetting(myKey);
196
+ if (value === true) {
197
+ // Legacy boolean requests are ambiguous and may be stale from
198
+ // a prior crashed session. New stop requests use token payloads.
199
+ writeSetting(myKey, false);
200
+ } else {
201
+ const payload = decodePayload(value);
202
+ if (payload) {
203
+ handleStopRequest(myKey, payload);
204
+ }
183
205
  }
184
206
  }
185
207
  task.wait(POLL_INTERVAL_SEC);
@@ -189,28 +211,32 @@ function startMonitor(): void {
189
211
 
190
212
  function requestStop(): StopRequestResult {
191
213
  if (!pluginRef) return { ok: false };
192
- const myKey = settingKey(computeInstanceId());
193
214
  const requestId = HttpService.GenerateGUID(false);
194
- const ok = writePayload(myKey, {
215
+ const payload: StopPayload = {
195
216
  kind: "request",
196
217
  id: requestId,
197
218
  requestedAt: tick(),
198
- });
219
+ };
220
+ let ok = false;
221
+ for (const myKey of settingKeys()) {
222
+ ok = writePayload(myKey, payload) || ok;
223
+ }
199
224
  return { ok, requestId: ok ? requestId : undefined };
200
225
  }
201
226
 
202
227
  function waitForConsumption(requestId: string): StopConsumptionResult {
203
228
  if (!pluginRef) return { ok: false, consumed: false, error: "Plugin reference is not initialized." };
204
- const myKey = settingKey(computeInstanceId());
205
229
  const start = tick();
206
230
  while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
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
- };
231
+ for (const myKey of settingKeys()) {
232
+ const payload = decodePayload(readSetting(myKey));
233
+ if (payload && payload.kind === "result" && payload.id === requestId) {
234
+ return {
235
+ ok: payload.ok === true,
236
+ consumed: true,
237
+ error: payload.error,
238
+ };
239
+ }
214
240
  }
215
241
  task.wait(WAIT_POLL_SEC);
216
242
  }
@@ -223,12 +249,13 @@ function waitForConsumption(requestId: string): StopConsumptionResult {
223
249
 
224
250
  function clearPending(requestId?: string): void {
225
251
  if (!pluginRef) return;
226
- const myKey = settingKey(computeInstanceId());
227
- if (requestId !== undefined) {
228
- const payload = decodePayload(readSetting(myKey));
229
- if (payload && payload.id !== requestId) return;
252
+ for (const myKey of settingKeys()) {
253
+ if (requestId !== undefined) {
254
+ const payload = decodePayload(readSetting(myKey));
255
+ if (payload && payload.id !== requestId) continue;
256
+ }
257
+ writeSetting(myKey, false);
230
258
  }
231
- writeSetting(myKey, false);
232
259
  }
233
260
 
234
261
  export = {