@chrrxs/robloxstudio-mcp 2.16.0 → 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.
@@ -7,6 +7,7 @@ import InputHandlers from "./handlers/InputHandlers";
7
7
  import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
8
8
  import LuauExec from "./LuauExec";
9
9
  import State from "./State";
10
+ import HttpDiagnostics from "./HttpDiagnostics";
10
11
 
11
12
  interface StudioTestServiceMultiplayer extends StudioTestService {
12
13
  CanLeaveTest(): boolean;
@@ -64,7 +65,8 @@ function resolvePlaceName(): string {
64
65
  // is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
65
66
  // signaling, which works regardless of MCP server state.)
66
67
 
67
- const MCP_URL = "http://localhost:58741";
68
+ const DEFAULT_MCP_URL = "http://localhost:58741";
69
+ let mcpUrl = DEFAULT_MCP_URL;
68
70
  const BROKER_NAME = "__MCPClientBroker";
69
71
  const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
70
72
 
@@ -152,7 +154,7 @@ function forkRole(): "edit" | "server" | "client" {
152
154
  function postJson(endpoint: string, body: Record<string, unknown>) {
153
155
  return pcall(() =>
154
156
  HttpService.RequestAsync({
155
- Url: `${MCP_URL}${endpoint}`,
157
+ Url: `${mcpUrl}${endpoint}`,
156
158
  Method: "POST",
157
159
  Headers: { "Content-Type": "application/json" },
158
160
  Body: HttpService.JSONEncode(body),
@@ -160,6 +162,20 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
160
162
  );
161
163
  }
162
164
 
165
+ function formatPostJsonFailure(endpoint: string, ok: boolean, res: unknown): string {
166
+ return HttpDiagnostics.formatRequestFailure(`${mcpUrl}${endpoint}`, ok, res);
167
+ }
168
+
169
+ function setServerUrl(serverUrl: string | undefined): void {
170
+ if (serverUrl !== undefined && serverUrl !== "") {
171
+ mcpUrl = serverUrl;
172
+ }
173
+ }
174
+
175
+ function getServerUrl(): string {
176
+ return mcpUrl;
177
+ }
178
+
163
179
  function handleExecuteLuau(data: Record<string, unknown> | undefined) {
164
180
  const code = data && (data.code as string | undefined);
165
181
  if (typeIs(code, "string") === false || code === "") {
@@ -277,13 +293,14 @@ function setupClientBroker() {
277
293
  }
278
294
 
279
295
  const proxyByPlayer = new Map<Player, ProxyEntry>();
296
+ const proxyRegisterFailuresByPlayer = new Set<Player>();
280
297
  let serverBrokerStarted = false;
281
298
 
282
299
  function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
283
300
  while (player.Parent !== undefined && proxyByPlayer.has(player)) {
284
301
  const [ok, res] = pcall(() =>
285
302
  HttpService.RequestAsync({
286
- Url: `${MCP_URL}/poll?pluginSessionId=${proxyId}`,
303
+ Url: `${mcpUrl}/poll?pluginSessionId=${proxyId}`,
287
304
  Method: "GET",
288
305
  Headers: { "Content-Type": "application/json" },
289
306
  }),
@@ -341,18 +358,23 @@ function registerProxy(player: Player, rf: RemoteFunction) {
341
358
  pluginVariant: State.PLUGIN_VARIANT,
342
359
  });
343
360
  if (!ok || !res || !res.Success) {
344
- warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}`);
361
+ proxyRegisterFailuresByPlayer.add(player);
362
+ warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}: ${formatPostJsonFailure("/ready", ok, res)}`);
345
363
  return;
346
364
  }
347
365
  const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
348
366
  const assigned = body.assignedRole ?? "client";
349
367
  proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
368
+ if (proxyRegisterFailuresByPlayer.has(player)) {
369
+ proxyRegisterFailuresByPlayer.delete(player);
370
+ print(`[robloxstudio-mcp] proxy registered for ${player.Name} as ${assigned} via ${mcpUrl}`);
371
+ }
350
372
  task.spawn(pollProxy, proxyId, player, rf);
351
373
  }
352
374
 
353
375
  // (Removed: startEditProxyLoop. The play-server DM no longer registers an
354
376
  // "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
355
- // plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
377
+ // plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
356
378
  // which doesn't depend on MCP server state or peer registration at all.)
357
379
 
358
380
  function setupServerBroker() {
@@ -377,6 +399,7 @@ function setupServerBroker() {
377
399
  const entry = proxyByPlayer.get(p);
378
400
  if (entry) {
379
401
  proxyByPlayer.delete(p);
402
+ proxyRegisterFailuresByPlayer.delete(p);
380
403
  postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
381
404
  }
382
405
  });
@@ -389,7 +412,10 @@ function setupServerBroker() {
389
412
  }
390
413
 
391
414
  export = {
392
- MCP_URL,
415
+ MCP_URL: DEFAULT_MCP_URL,
416
+ DEFAULT_MCP_URL,
417
+ getServerUrl,
418
+ setServerUrl,
393
419
  forkRole,
394
420
  setupClientBroker,
395
421
  setupServerBroker,
@@ -18,6 +18,8 @@ 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 ServerUrlSettings from "./ServerUrlSettings";
22
+ import HttpDiagnostics from "./HttpDiagnostics";
21
23
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
22
24
 
23
25
  // Per-plugin-load random GUID. Used as the /poll URL param so the server
@@ -50,6 +52,7 @@ let assignedRole: string | undefined;
50
52
  let duplicateInstanceRole = false;
51
53
  let hasVersionMismatch = false;
52
54
  let lastVersionMismatchWarningKey: string | undefined;
55
+ const readyFailureLogKeys = new Set<string>();
53
56
 
54
57
  // Cache the published place name from MarketplaceService:GetProductInfo so
55
58
  // /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
@@ -249,30 +252,44 @@ function sendReady(conn: Connection): void {
249
252
  }),
250
253
  });
251
254
  });
252
- if (!readyOk) return;
253
- // 409 = duplicate_instance_role. Surface in UI and stop polling.
254
- if (readyResult.StatusCode === 409) {
255
- duplicateInstanceRole = true;
256
- conn.isActive = false;
257
- const ui = UI.getElements();
258
- if (State.getActiveTabIndex() === 0) {
259
- ui.statusLabel.Text = "Duplicate instance";
260
- ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
261
- ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role";
262
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
263
- }
264
- warn(
265
- `[MCPPlugin] Another Studio is already connected as (${instanceId}, ${detectRole()}). Close the other Studio window or this one.`,
266
- );
255
+ const readyUrl = `${conn.serverUrl}/ready`;
256
+ const readyRole = detectRole();
257
+ const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
258
+ if (!readyOk) {
259
+ readyFailureLogKeys.add(readyLogKey);
260
+ warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
267
261
  return;
268
262
  }
269
- if (readyResult.Success) {
270
- const [parseOk, readyData] = pcall(
271
- () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
272
- );
273
- if (parseOk && readyData.assignedRole) {
274
- assignedRole = readyData.assignedRole;
263
+ if (!readyResult.Success) {
264
+ const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
265
+ readyFailureLogKeys.add(readyLogKey);
266
+ // 409 = duplicate_instance_role. Surface in UI and stop polling.
267
+ if (readyResult.StatusCode === 409) {
268
+ duplicateInstanceRole = true;
269
+ conn.isActive = false;
270
+ const ui = UI.getElements();
271
+ if (State.getActiveTabIndex() === 0) {
272
+ ui.statusLabel.Text = "Duplicate instance";
273
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
274
+ ui.detailStatusLabel.Text = reason;
275
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
276
+ }
277
+ warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
278
+ return;
275
279
  }
280
+ warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
281
+ return;
282
+ }
283
+ const [parseOk, readyData] = pcall(
284
+ () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
285
+ );
286
+ if (parseOk && readyData.assignedRole) {
287
+ assignedRole = readyData.assignedRole;
288
+ }
289
+ const connectedRole = assignedRole ?? detectRole();
290
+ if (readyFailureLogKeys.has(readyLogKey)) {
291
+ readyFailureLogKeys.delete(readyLogKey);
292
+ print(`[robloxstudio-mcp] /ready connected for ${instanceId}/${connectedRole} via ${conn.serverUrl}`);
276
293
  }
277
294
  });
278
295
  }
@@ -313,7 +330,7 @@ function pollForRequests(connIndex: number) {
313
330
  const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
314
331
  if (lastVersionMismatchWarningKey !== warningKey) {
315
332
  lastVersionMismatchWarningKey = warningKey;
316
- warn(`[MCPPlugin] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
333
+ warn(`[robloxstudio-mcp] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
317
334
  }
318
335
  UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
319
336
  } else if (hasVersionMismatch) {
@@ -470,6 +487,7 @@ function activatePlugin(connIndex?: number) {
470
487
  UI.updateTabLabel(idx);
471
488
  UI.updateUIState();
472
489
  }
490
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl);
473
491
  UI.updateTabDot(idx);
474
492
 
475
493
  if (!conn.heartbeatConnection) {
@@ -0,0 +1,50 @@
1
+ import { HttpService } from "@rbxts/services";
2
+
3
+ interface FailureBody {
4
+ error?: string;
5
+ message?: string;
6
+ missingFields?: unknown;
7
+ request?: unknown;
8
+ existing?: unknown;
9
+ details?: unknown;
10
+ }
11
+
12
+ function encodeForLog(value: unknown): string {
13
+ const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
14
+ return ok ? encoded : tostring(value);
15
+ }
16
+
17
+ function formatBody(body: string): string {
18
+ if (body === "") return "";
19
+ const [ok, decoded] = pcall(() => HttpService.JSONDecode(body));
20
+ if (ok && typeIs(decoded, "table")) {
21
+ const data = decoded as FailureBody;
22
+ const parts: string[] = [];
23
+ if (typeIs(data.error, "string") && data.error !== "") parts.push(`error=${data.error}`);
24
+ if (typeIs(data.message, "string") && data.message !== "") parts.push(`message=${data.message}`);
25
+ if (data.missingFields !== undefined) parts.push(`missingFields=${encodeForLog(data.missingFields)}`);
26
+ if (data.request !== undefined) parts.push(`request=${encodeForLog(data.request)}`);
27
+ if (data.existing !== undefined) parts.push(`existing=${encodeForLog(data.existing)}`);
28
+ if (data.details !== undefined) parts.push(`details=${encodeForLog(data.details)}`);
29
+ if (parts.size() > 0) return parts.join(" ");
30
+ }
31
+ return `body=${body}`;
32
+ }
33
+
34
+ function formatRequestFailure(url: string, ok: boolean, res: unknown): string {
35
+ if (!ok) {
36
+ return `RequestAsync threw for ${url}: ${tostring(res)}`;
37
+ }
38
+ if (res === undefined) {
39
+ return `RequestAsync returned no response for ${url}`;
40
+ }
41
+ const response = res as RequestAsyncResponse;
42
+ const statusMessage = response.StatusMessage !== "" ? ` ${response.StatusMessage}` : "";
43
+ const body = formatBody(response.Body);
44
+ const suffix = body !== "" ? `: ${body}` : "";
45
+ return `HTTP ${response.StatusCode}${statusMessage} from ${url}${suffix}`;
46
+ }
47
+
48
+ export = {
49
+ formatRequestFailure,
50
+ };
@@ -0,0 +1,48 @@
1
+ import { HttpService, ServerStorage } from "@rbxts/services";
2
+
3
+ const SETTING_KEY_PREFIX = "MCP_SERVER_URL_";
4
+
5
+ let pluginRef: Plugin | undefined;
6
+
7
+ function init(p: Plugin): void {
8
+ pluginRef = p;
9
+ }
10
+
11
+ function computeInstanceId(): string {
12
+ if (game.PlaceId !== 0) {
13
+ return `place:${tostring(game.PlaceId)}`;
14
+ }
15
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
16
+ if (typeIs(existing, "string") && existing !== "") {
17
+ return `anon:${existing as string}`;
18
+ }
19
+ const fresh = HttpService.GenerateGUID(false);
20
+ pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
21
+ return `anon:${fresh}`;
22
+ }
23
+
24
+ function settingKey(instanceId: string): string {
25
+ return SETTING_KEY_PREFIX + instanceId;
26
+ }
27
+
28
+ function rememberServerUrl(serverUrl: string): void {
29
+ if (!pluginRef || serverUrl === "") return;
30
+ const key = settingKey(computeInstanceId());
31
+ pcall(() => pluginRef!.SetSetting(key, serverUrl));
32
+ }
33
+
34
+ function readServerUrl(): string | undefined {
35
+ 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;
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ export = {
45
+ init,
46
+ rememberServerUrl,
47
+ readServerUrl,
48
+ };
@@ -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 = {
@@ -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();