@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.
@@ -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
@@ -45,20 +47,24 @@ function computeInstanceId(): string {
45
47
  return `anon:${fresh}`;
46
48
  }
47
49
 
48
- const instanceId = computeInstanceId();
49
50
  let assignedRole: string | undefined;
50
51
  let duplicateInstanceRole = false;
51
52
  let hasVersionMismatch = false;
52
53
  let lastVersionMismatchWarningKey: string | undefined;
54
+ let lastReadyInstanceId: 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
56
59
  // from game.Name (the DataModel name, often "Place1" in edit). We only fetch
57
60
  // once per plugin load; the published name doesn't change mid-session.
58
61
  let cachedPlaceName: string | undefined;
62
+ let cachedPlaceNamePlaceId: number | undefined;
59
63
 
60
64
  function resolvePlaceName(): string {
61
- if (cachedPlaceName !== undefined) return cachedPlaceName;
65
+ if (cachedPlaceName !== undefined && cachedPlaceNamePlaceId === game.PlaceId) return cachedPlaceName;
66
+ cachedPlaceName = undefined;
67
+ cachedPlaceNamePlaceId = game.PlaceId;
62
68
  if (game.PlaceId === 0) {
63
69
  cachedPlaceName = game.Name;
64
70
  return cachedPlaceName;
@@ -206,21 +212,31 @@ function getConnectionStatus(connIndex: number): string {
206
212
  // restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
207
213
  let lastReadyPostAt = 0;
208
214
 
209
- // game.Name is sometimes "Place1" at plugin-load time and only settles to
210
- // the real DataModel name (e.g. "Game" once playtest spawns the play DM)
211
- // after Studio finishes wiring things up. Re-fire /ready when it changes so
212
- // get_connected_instances doesn't show a stale dataModelName forever. Set
213
- // up once per plugin load — the connection passed in is whichever was
214
- // active when activatePlugin was first called.
215
+ // game.Name and game.PlaceId can both settle after plugin load. PlaceId also
216
+ // changes when an unpublished file is published while MCP is already active.
217
+ // Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
215
218
  let nameChangeConn: RBXScriptConnection | undefined;
216
- function ensureNameChangeWatcher(conn: Connection): void {
217
- if (nameChangeConn) return;
218
- const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
219
- if (!okSig || !signal) return;
220
- nameChangeConn = signal.Connect(() => {
221
- // sendReady has its own 2s throttle, so rapid burst changes coalesce.
222
- sendReady(conn);
223
- });
219
+ let placeIdChangeConn: RBXScriptConnection | undefined;
220
+ function ensureIdentityWatcher(conn: Connection): void {
221
+ if (!nameChangeConn) {
222
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
223
+ if (okSig && signal) {
224
+ nameChangeConn = signal.Connect(() => {
225
+ // sendReady has its own 2s throttle, so rapid burst changes coalesce.
226
+ sendReady(conn);
227
+ });
228
+ }
229
+ }
230
+ if (!placeIdChangeConn) {
231
+ const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("PlaceId"));
232
+ if (okSig && signal) {
233
+ placeIdChangeConn = signal.Connect(() => {
234
+ cachedPlaceName = undefined;
235
+ cachedPlaceNamePlaceId = undefined;
236
+ sendReady(conn);
237
+ });
238
+ }
239
+ }
224
240
  }
225
241
 
226
242
  function sendReady(conn: Connection): void {
@@ -228,6 +244,7 @@ function sendReady(conn: Connection): void {
228
244
  const now = tick();
229
245
  if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
230
246
  lastReadyPostAt = now;
247
+ const instanceId = computeInstanceId();
231
248
  task.spawn(() => {
232
249
  const [readyOk, readyResult] = pcall(() => {
233
250
  return HttpService.RequestAsync({
@@ -249,30 +266,47 @@ function sendReady(conn: Connection): void {
249
266
  }),
250
267
  });
251
268
  });
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
- );
269
+ const readyUrl = `${conn.serverUrl}/ready`;
270
+ const readyRole = detectRole();
271
+ const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
272
+ if (!readyOk) {
273
+ readyFailureLogKeys.add(readyLogKey);
274
+ warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
267
275
  return;
268
276
  }
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;
277
+ if (!readyResult.Success) {
278
+ const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
279
+ readyFailureLogKeys.add(readyLogKey);
280
+ // 409 = duplicate_instance_role. Surface in UI and stop polling.
281
+ if (readyResult.StatusCode === 409) {
282
+ duplicateInstanceRole = true;
283
+ conn.isActive = false;
284
+ const ui = UI.getElements();
285
+ if (State.getActiveTabIndex() === 0) {
286
+ ui.statusLabel.Text = "Duplicate instance";
287
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
288
+ ui.detailStatusLabel.Text = reason;
289
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
290
+ }
291
+ warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
292
+ return;
275
293
  }
294
+ warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
295
+ return;
296
+ }
297
+ const [parseOk, readyData] = pcall(
298
+ () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
299
+ );
300
+ if (parseOk && readyData.assignedRole) {
301
+ assignedRole = readyData.assignedRole;
302
+ }
303
+ lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
304
+ ? readyData.instanceId
305
+ : instanceId;
306
+ const connectedRole = assignedRole ?? detectRole();
307
+ if (readyFailureLogKeys.has(readyLogKey)) {
308
+ readyFailureLogKeys.delete(readyLogKey);
309
+ print(`[robloxstudio-mcp] /ready connected for ${instanceId}/${connectedRole} via ${conn.serverUrl}`);
276
310
  }
277
311
  });
278
312
  }
@@ -313,7 +347,7 @@ function pollForRequests(connIndex: number) {
313
347
  const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
314
348
  if (lastVersionMismatchWarningKey !== warningKey) {
315
349
  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.`);
350
+ 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
351
  }
318
352
  UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
319
353
  } else if (hasVersionMismatch) {
@@ -470,11 +504,18 @@ function activatePlugin(connIndex?: number) {
470
504
  UI.updateTabLabel(idx);
471
505
  UI.updateUIState();
472
506
  }
507
+ ServerUrlSettings.rememberServerUrl(conn.serverUrl);
473
508
  UI.updateTabDot(idx);
474
509
 
475
510
  if (!conn.heartbeatConnection) {
476
511
  conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
477
512
  const now = tick();
513
+ const currentInstanceId = computeInstanceId();
514
+ if (lastReadyInstanceId !== undefined && currentInstanceId !== lastReadyInstanceId) {
515
+ cachedPlaceName = undefined;
516
+ cachedPlaceNamePlaceId = undefined;
517
+ sendReady(conn);
518
+ }
478
519
  const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
479
520
  if (now - conn.lastPoll > currentInterval) {
480
521
  conn.lastPoll = now;
@@ -493,9 +534,8 @@ function activatePlugin(connIndex?: number) {
493
534
  task.spawn(cleanupLegacyEditBridges);
494
535
  }
495
536
 
496
- // Watch for game.Name updates so a stale "Place1" captured at first
497
- // /ready gets refreshed once Studio settles on the real DM name.
498
- ensureNameChangeWatcher(conn);
537
+ // Watch identity fields so stale name or anon instance ids are refreshed.
538
+ ensureIdentityWatcher(conn);
499
539
  }
500
540
 
501
541
  function deactivatePlugin(connIndex?: number) {
@@ -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,61 @@
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 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[] = [];
19
+ if (game.PlaceId !== 0) {
20
+ addUnique(ids, `place:${tostring(game.PlaceId)}`);
21
+ }
22
+ const existing = ServerStorage.GetAttribute("__MCPPlaceId");
23
+ if (typeIs(existing, "string") && existing !== "") {
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}`);
29
+ }
30
+ return ids;
31
+ }
32
+
33
+ function settingKey(instanceId: string): string {
34
+ return SETTING_KEY_PREFIX + instanceId;
35
+ }
36
+
37
+ function rememberServerUrl(serverUrl: string): void {
38
+ if (!pluginRef || serverUrl === "") return;
39
+ for (const instanceId of computeInstanceIds()) {
40
+ const key = settingKey(instanceId);
41
+ pcall(() => pluginRef!.SetSetting(key, serverUrl));
42
+ }
43
+ }
44
+
45
+ function readServerUrl(): string | undefined {
46
+ if (!pluginRef) return undefined;
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
+ }
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ export = {
58
+ init,
59
+ rememberServerUrl,
60
+ readServerUrl,
61
+ };