@chrrxs/robloxstudio-mcp 2.16.0 → 2.16.2

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,43 +1,69 @@
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
7
10
  // DMs, play-client DMs). For each connected place we use a dedicated key
8
- // "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
11
+ // "MCP_STOP_PLAY_<instanceId>" as a tiny request/result mailbox:
9
12
  //
10
- // * The edit DM's stopPlaytest handler writes `true` into its own key
13
+ // * The edit DM's handler writes a tokenized stop request into its own key
11
14
  // (computed from its placeId / ServerStorage anon UUID).
12
15
  // * 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.
16
+ // instanceId at 1Hz. On a fresh token, it calls StudioTestService:EndTest
17
+ // and writes a matching result token. Play-server DMs for other places
18
+ // never touch this key.
19
+ // * The edit DM waits up to ~8s for its result token, confirming a matching
20
+ // play-server actually consumed the request.
18
21
  //
19
22
  // Earlier versions used a single shared boolean flag, which let any
20
23
  // play-server DM in the same Studio process consume any place's stop
21
24
  // request — silently yanking teammates' playtests. The per-key scoping
22
25
  // below is the fix.
23
26
 
24
- import { HttpService, ServerStorage } from "@rbxts/services";
27
+ import { HttpService, RunService, ServerStorage } from "@rbxts/services";
25
28
 
26
29
  const StudioTestService = game.GetService("StudioTestService");
27
30
 
28
31
  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;
32
+ // Keep this conservative. plugin:GetSetting is backed by Studio's plugin
33
+ // settings store, and this monitor runs during every play session, including
34
+ // manually-started Play. The official reference implementation polls at 1s.
35
+ const POLL_INTERVAL_SEC = 1;
33
36
  // Total time we wait for the matching play-server DM to consume the
34
37
  // signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
35
38
  // StudioTestService:EndTest teardown (several seconds on heavier places).
36
- // 8s is comfortable; the tighter poll above keeps real cases well under.
39
+ // 8s is intentionally shorter than the MCP request timeout but long enough
40
+ // for the 1s monitor cadence plus ordinary Studio teardown latency.
37
41
  const WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0;
38
42
  const WAIT_POLL_SEC = 0.1;
43
+ const REQUEST_TTL_SEC = 12.0;
39
44
 
40
45
  let pluginRef: Plugin | undefined;
46
+ let endTestIssued = false;
47
+
48
+ interface StopPayload {
49
+ kind?: string;
50
+ id?: string;
51
+ requestedAt?: number;
52
+ consumedAt?: number;
53
+ ok?: boolean;
54
+ error?: string;
55
+ }
56
+
57
+ interface StopRequestResult {
58
+ ok: boolean;
59
+ requestId?: string;
60
+ }
61
+
62
+ interface StopConsumptionResult {
63
+ ok: boolean;
64
+ consumed: boolean;
65
+ error?: string;
66
+ }
41
67
 
42
68
  function init(p: Plugin): void {
43
69
  pluginRef = p;
@@ -48,70 +74,183 @@ function init(p: Plugin): void {
48
74
  // agree on the place identifier (published places: placeId; unpublished:
49
75
  // UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
50
76
  // into the play DM).
51
- 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[] = [];
52
85
  if (game.PlaceId !== 0) {
53
- return `place:${tostring(game.PlaceId)}`;
86
+ addUnique(ids, `place:${tostring(game.PlaceId)}`);
54
87
  }
55
88
  const existing = ServerStorage.GetAttribute("__MCPPlaceId");
56
89
  if (typeIs(existing, "string") && existing !== "") {
57
- 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}`);
58
95
  }
59
- const fresh = HttpService.GenerateGUID(false);
60
- pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
61
- return `anon:${fresh}`;
96
+ return ids;
62
97
  }
63
98
 
64
99
  function settingKey(instanceId: string): string {
65
100
  return SETTING_KEY_PREFIX + instanceId;
66
101
  }
67
102
 
103
+ function settingKeys(): string[] {
104
+ return computeInstanceIds().map((instanceId) => settingKey(instanceId));
105
+ }
106
+
107
+ function readSetting(key: string): unknown {
108
+ if (!pluginRef) return undefined;
109
+ const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
110
+ return ok ? value : undefined;
111
+ }
112
+
113
+ function writeSetting(key: string, value: unknown): boolean {
114
+ if (!pluginRef) return false;
115
+ const [ok] = pcall(() => pluginRef!.SetSetting(key, value));
116
+ return ok;
117
+ }
118
+
119
+ function decodePayload(value: unknown): StopPayload | undefined {
120
+ let decoded = value;
121
+ if (typeIs(value, "string")) {
122
+ const [ok, result] = pcall(() => HttpService.JSONDecode(value as string));
123
+ if (!ok) return undefined;
124
+ decoded = result;
125
+ }
126
+ if (!typeIs(decoded, "table")) return undefined;
127
+ const payload = decoded as StopPayload;
128
+ if (!typeIs(payload.kind, "string") || !typeIs(payload.id, "string")) {
129
+ return undefined;
130
+ }
131
+ return payload;
132
+ }
133
+
134
+ function writePayload(key: string, payload: StopPayload): boolean {
135
+ const [encodedOk, encoded] = pcall(() => HttpService.JSONEncode(payload));
136
+ if (!encodedOk || !typeIs(encoded, "string")) return false;
137
+ return writeSetting(key, encoded);
138
+ }
139
+
140
+ function writeResult(key: string, request: StopPayload, ok: boolean, errText?: string): void {
141
+ writePayload(key, {
142
+ kind: "result",
143
+ id: request.id,
144
+ requestedAt: request.requestedAt,
145
+ consumedAt: tick(),
146
+ ok,
147
+ error: errText,
148
+ });
149
+ }
150
+
151
+ function handleStopRequest(key: string, request: StopPayload): void {
152
+ if (request.kind !== "request" || !typeIs(request.id, "string")) return;
153
+ if (!typeIs(request.requestedAt, "number")) {
154
+ writeSetting(key, false);
155
+ return;
156
+ }
157
+
158
+ const age = tick() - request.requestedAt;
159
+ if (age < -5 || age > REQUEST_TTL_SEC) {
160
+ writeSetting(key, false);
161
+ return;
162
+ }
163
+
164
+ if (endTestIssued) {
165
+ writeResult(key, request, true);
166
+ return;
167
+ }
168
+
169
+ if (!RunService.IsRunning() || !RunService.IsServer()) {
170
+ writeResult(key, request, false, "StopPlayMonitor is not running in the server DataModel.");
171
+ return;
172
+ }
173
+
174
+ endTestIssued = true;
175
+ const [endOk, endErr] = pcall(() => StudioTestService.EndTest("stopped_by_mcp"));
176
+ writeResult(key, request, endOk, endOk ? undefined : tostring(endErr));
177
+ if (!endOk) {
178
+ endTestIssued = false;
179
+ }
180
+ }
181
+
68
182
  function startMonitor(): void {
69
183
  if (!pluginRef) {
70
- warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping");
184
+ warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping");
71
185
  return;
72
186
  }
73
- 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
187
  task.spawn(() => {
79
188
  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"));
189
+ for (const myKey of settingKeys()) {
190
+ const value = readSetting(myKey);
191
+ if (value === true) {
192
+ // Legacy boolean requests are ambiguous and may be stale from
193
+ // a prior crashed session. New stop requests use token payloads.
194
+ writeSetting(myKey, false);
195
+ } else {
196
+ const payload = decodePayload(value);
197
+ if (payload) {
198
+ handleStopRequest(myKey, payload);
199
+ }
200
+ }
86
201
  }
87
202
  task.wait(POLL_INTERVAL_SEC);
88
203
  }
89
204
  });
90
205
  }
91
206
 
92
- function requestStop(): boolean {
93
- if (!pluginRef) return false;
94
- const myKey = settingKey(computeInstanceId());
95
- const [ok] = pcall(() => pluginRef!.SetSetting(myKey, true));
96
- return ok;
207
+ function requestStop(): StopRequestResult {
208
+ if (!pluginRef) return { ok: false };
209
+ const requestId = HttpService.GenerateGUID(false);
210
+ const payload: StopPayload = {
211
+ kind: "request",
212
+ id: requestId,
213
+ requestedAt: tick(),
214
+ };
215
+ let ok = false;
216
+ for (const myKey of settingKeys()) {
217
+ ok = writePayload(myKey, payload) || ok;
218
+ }
219
+ return { ok, requestId: ok ? requestId : undefined };
97
220
  }
98
221
 
99
- function waitForConsumption(): boolean {
100
- if (!pluginRef) return false;
101
- const myKey = settingKey(computeInstanceId());
222
+ function waitForConsumption(requestId: string): StopConsumptionResult {
223
+ if (!pluginRef) return { ok: false, consumed: false, error: "Plugin reference is not initialized." };
102
224
  const start = tick();
103
225
  while (tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC) {
104
- const [okGet, val] = pcall(() => pluginRef!.GetSetting(myKey));
105
- if (okGet && val !== true) return true;
226
+ for (const myKey of settingKeys()) {
227
+ const payload = decodePayload(readSetting(myKey));
228
+ if (payload && payload.kind === "result" && payload.id === requestId) {
229
+ return {
230
+ ok: payload.ok === true,
231
+ consumed: true,
232
+ error: payload.error,
233
+ };
234
+ }
235
+ }
106
236
  task.wait(WAIT_POLL_SEC);
107
237
  }
108
- return false;
238
+ return {
239
+ ok: false,
240
+ consumed: false,
241
+ error: "Timed out waiting for the play-server DataModel to acknowledge stop_playtest.",
242
+ };
109
243
  }
110
244
 
111
- function clearPending(): void {
245
+ function clearPending(requestId?: string): void {
112
246
  if (!pluginRef) return;
113
- const myKey = settingKey(computeInstanceId());
114
- pcall(() => pluginRef!.SetSetting(myKey, false));
247
+ for (const myKey of settingKeys()) {
248
+ if (requestId !== undefined) {
249
+ const payload = decodePayload(readSetting(myKey));
250
+ if (payload && payload.id !== requestId) continue;
251
+ }
252
+ writeSetting(myKey, false);
253
+ }
115
254
  }
116
255
 
117
256
  export = {
@@ -231,7 +231,7 @@ function startPlaytest(requestData: Record<string, unknown>) {
231
231
 
232
232
  const [injected, injErr] = pcall(() => injectStopListener());
233
233
  if (!injected) {
234
- warn(`[MCP] Failed to inject stop listener: ${injErr}`);
234
+ warn(`[robloxstudio-mcp] Failed to inject stop listener: ${injErr}`);
235
235
  }
236
236
 
237
237
  task.spawn(() => {
@@ -266,15 +266,15 @@ function startPlaytest(requestData: Record<string, unknown>) {
266
266
  }
267
267
 
268
268
  function stopPlaytest(_requestData: Record<string, unknown>) {
269
- // Signal the play-server DM's StopPlayMonitor via plugin:SetSetting (a
270
- // cross-DM persistent store). The monitor polls at 1Hz, sees the flag,
271
- // calls StudioTestService:EndTest, then resets the flag. We wait up to
272
- // 2.5s for the reset to confirm a play DM actually consumed the request,
273
- // which avoids returning success when nothing is running.
274
- 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) {
275
274
  return { error: "Plugin not ready. Try again in a moment." };
276
275
  }
277
- if (!StopPlayMonitor.waitForConsumption()) {
276
+ const consumption = StopPlayMonitor.waitForConsumption(stopRequest.requestId);
277
+ if (!consumption.ok) {
278
278
  // Two distinct failure modes collapse here, distinguished by whether
279
279
  // THIS edit DM has a playtest tracked:
280
280
  //
@@ -286,19 +286,24 @@ function stopPlaytest(_requestData: Record<string, unknown>) {
286
286
  // from the caller's perspective — playtest may actually have ended).
287
287
  // Tell the caller it's a timing issue and they can retry.
288
288
  //
289
- // 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
290
290
  // doesn't fire EndTest on startup against a stale signal.
291
- StopPlayMonitor.clearPending();
291
+ StopPlayMonitor.clearPending(stopRequest.requestId);
292
292
  if (testRunning) {
293
293
  return {
294
294
  error:
295
- "Playtest stop signal sent but consumption confirmation timed out. " +
295
+ "Playtest stop signal failed or was not acknowledged. " +
296
296
  "The playtest may have ended anyway; check get_connected_instances.",
297
+ detail: consumption.error,
297
298
  };
298
299
  }
299
- 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 };
300
304
  }
301
- // Flag was consumed (EndTest called). ExecutePlayModeAsync in our
305
+ StopPlayMonitor.clearPending(stopRequest.requestId);
306
+ // Request was consumed (EndTest called). ExecutePlayModeAsync in our
302
307
  // startPlaytest task.spawn is still unwinding though — testRunning stays
303
308
  // true until that yield completes and the post-block runs. Wait so
304
309
  // back-to-back stop -> start sequences don't race against the prior
@@ -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 ServerUrlSettings from "../modules/ServerUrlSettings";
5
6
  import { cleanupLegacyEditBridges, ensureRuntimeBridgeInstalled } from "../modules/EvalBridges";
6
7
  import RuntimeLogBuffer from "../modules/RuntimeLogBuffer";
7
8
  import StopPlayMonitor from "../modules/StopPlayMonitor";
@@ -21,6 +22,7 @@ RuntimeLogBuffer.install();
21
22
  // edit DM (write the flag) and the play-server DM (read+act on the flag) can
22
23
  // access plugin:SetSetting/GetSetting.
23
24
  StopPlayMonitor.init(plugin);
25
+ ServerUrlSettings.init(plugin);
24
26
 
25
27
  UI.init(plugin);
26
28
  const elements = UI.getElements();
@@ -79,7 +81,7 @@ task.delay(2, () => {
79
81
  } else {
80
82
  const result = ensureRuntimeBridgeInstalled();
81
83
  if (!result.installed) {
82
- warn(`[MCPPlugin] Runtime eval bridge install failed: ${result.error}`);
84
+ warn(`[robloxstudio-mcp] Runtime eval bridge install failed: ${result.error}`);
83
85
  }
84
86
  }
85
87
  if (role === "edit" || role === "server") {
@@ -87,10 +89,19 @@ task.delay(2, () => {
87
89
  const idx = State.getActiveTabIndex();
88
90
  const conn = State.getConnection(idx);
89
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
+ }
90
100
  // Defensive default: in invisible play-DM UIs, the input field
91
101
  // may not be populated by the time we activate.
92
102
  if (conn.serverUrl === undefined || conn.serverUrl === "") {
93
- conn.serverUrl = ClientBroker.MCP_URL;
103
+ conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
104
+ elements.urlInput.Text = conn.serverUrl;
94
105
  }
95
106
  Communication.activatePlugin(idx);
96
107
  }
@@ -99,8 +110,8 @@ task.delay(2, () => {
99
110
  if (role === "server") {
100
111
  ClientBroker.setupServerBroker();
101
112
  // The play-server DM is the only one where StudioTestService:EndTest is
102
- // legal, so the stop-play monitor lives here. Reads MCP_STOP_PLAY_SIGNAL
103
- // 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.
104
115
  StopPlayMonitor.startMonitor();
105
116
  } else if (role === "client") {
106
117
  ClientBroker.setupClientBroker();