@chrrxs/robloxstudio-mcp-inspector 2.15.2 → 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,
@@ -2,7 +2,7 @@ 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
+ import { cleanupLegacyEditBridges } from "./EvalBridges";
6
6
  import QueryHandlers from "./handlers/QueryHandlers";
7
7
  import PropertyHandlers from "./handlers/PropertyHandlers";
8
8
  import InstanceHandlers from "./handlers/InstanceHandlers";
@@ -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) {
@@ -487,18 +505,10 @@ function activatePlugin(connIndex?: number) {
487
505
  // later reports knownInstance=false (process restart, etc).
488
506
  sendReady(conn);
489
507
 
490
- // Keep the eval bridges present in the edit DM so that ANY playtest —
491
- // including one the dev starts manually via the Studio Play button —
492
- // clones them into the play DMs and eval_*_runtime works with no setup
493
- // roundtrip. Only the edit DM installs; play DMs already have the cloned
494
- // copies. Idempotent, so reconnects don't re-dirty the place.
508
+ // Remove legacy edit-mode eval bridge scripts from older plugin builds.
509
+ // Current bridges are created only in running play DataModels.
495
510
  if (!RunService.IsRunning()) {
496
- task.spawn(() => {
497
- const result = ensureBridgesInstalled();
498
- if (!result.installed) {
499
- warn(`[MCPPlugin] Eval bridge install failed: ${result.error}`);
500
- }
501
- });
511
+ task.spawn(cleanupLegacyEditBridges);
502
512
  }
503
513
 
504
514
  // Watch for game.Name updates so a stale "Place1" captured at first
@@ -21,30 +21,13 @@
21
21
  // ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
22
22
  // when LoadStringEnabled=false (the default in fresh places).
23
23
  //
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.
34
- //
35
- // Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
36
- // with Archivable=false (verified empirically in v2.9.0 testing - bridges
37
- // never reached the play DMs because we'd set them to false). We now keep
38
- // Archivable=true so the clone works, and rely on cleanupBridges() to
39
- // remove the scripts from the edit DM when the test ends. The only failure
40
- // mode is the user saving DURING an active playtest, which would persist
41
- // the bridges to the .rbxl - that's a no-op next session because
42
- // installBridges() always calls cleanupBridges() first to clear stale
43
- // instances. The RemoteFunction/BindableFunction that the bridge scripts
44
- // CREATE at runtime stay Archivable=false (they're runtime-only and should
45
- // never appear in a save).
46
-
47
- import { ServerScriptService, StarterPlayer } from "@rbxts/services";
24
+ // Lifecycle: bridge scripts are created only in running play DataModels.
25
+ // The server plugin peer creates the Script in runtime ServerScriptService;
26
+ // each client plugin peer creates its LocalScript in that client's
27
+ // PlayerScripts. Nothing is installed into the edit DataModel anymore.
28
+ // Runtime-created scripts disappear naturally when the playtest stops.
29
+
30
+ import { Players, ReplicatedStorage, RunService, ServerScriptService, StarterPlayer } from "@rbxts/services";
48
31
 
49
32
  const ScriptEditorService = game.GetService("ScriptEditorService");
50
33
 
@@ -122,12 +105,10 @@ end
122
105
  `;
123
106
 
124
107
  // Stamp written onto each installed bridge Script so we can tell whether the
125
- // bridge currently in the DM was produced by THIS plugin build. It's a djb2
126
- // hash of the actual bridge source plus the plugin version, so ANY change to
127
- // the source (or a version bump) yields a new stamp which makes
128
- // ensureBridgesInstalled() force a refresh on the next plugin load instead of
129
- // keeping a stale bridge that happens to still be present (e.g. one saved into
130
- // the .rbxl from an older build).
108
+ // runtime bridge currently in the play DM was produced by THIS plugin build.
109
+ // It's a djb2 hash of the actual bridge source plus the plugin version, so ANY
110
+ // change to the source (or a version bump) yields a new stamp and triggers a
111
+ // runtime refresh instead of keeping a stale bridge.
131
112
  const STAMP_ATTR = "__MCPBridgeStamp";
132
113
 
133
114
  function computeBridgeStamp(): string {
@@ -155,7 +136,12 @@ function setSource(scriptInst: Script | LocalScript, source: string): void {
155
136
  }
156
137
  }
157
138
 
158
- function findBridges(): { server?: Instance; client?: Instance } {
139
+ interface InstallResult {
140
+ installed: boolean;
141
+ error?: string;
142
+ }
143
+
144
+ function findLegacyEditBridges(): { server?: Instance; client?: Instance } {
159
145
  const sps = getStarterPlayerScripts();
160
146
  return {
161
147
  server: ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME),
@@ -163,8 +149,16 @@ function findBridges(): { server?: Instance; client?: Instance } {
163
149
  };
164
150
  }
165
151
 
166
- export function cleanupBridges(): void {
167
- const { server, client } = findBridges();
152
+ function destroyIfPresent(parent: Instance, name: string): void {
153
+ const existing = parent.FindFirstChild(name);
154
+ if (existing) {
155
+ pcall(() => existing.Destroy());
156
+ }
157
+ }
158
+
159
+ export function cleanupLegacyEditBridges(): void {
160
+ if (RunService.IsRunning()) return;
161
+ const { server, client } = findLegacyEditBridges();
168
162
  if (server) {
169
163
  pcall(() => server.Destroy());
170
164
  }
@@ -173,52 +167,75 @@ export function cleanupBridges(): void {
173
167
  }
174
168
  }
175
169
 
176
- // Idempotent variant: install only if the bridge scripts aren't already
177
- // present in the edit DM. Used to keep the bridges always available (so a
178
- // playtest the dev starts manually — not via the MCP start_playtest tool —
179
- // still clones them into the play DMs). Cheap no-op when already installed,
180
- // which avoids re-dirtying the place on every plugin reconnect.
181
- export function ensureBridgesInstalled(): { installed: boolean; error?: string } {
182
- const { server, client } = findBridges();
183
- if (server && client) {
184
- // Both present — but only skip the reinstall if they were produced by
185
- // THIS build. A mismatched/absent stamp means a stale bridge (older
186
- // plugin, or one persisted in the saved place), so force a refresh.
187
- const sStamp = server.GetAttribute(STAMP_ATTR);
188
- const cStamp = client.GetAttribute(STAMP_ATTR);
189
- if (sStamp === BRIDGE_STAMP && cStamp === BRIDGE_STAMP) {
190
- return { installed: true };
191
- }
170
+ function serverRuntimeBridgeReady(): boolean {
171
+ const scriptInst = ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME);
172
+ const bindable = ServerScriptService.FindFirstChild(BRIDGE_NAMES.serverLocal);
173
+ return scriptInst !== undefined &&
174
+ scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
175
+ bindable !== undefined &&
176
+ bindable.IsA("BindableFunction");
177
+ }
178
+
179
+ function getPlayerScripts(): Instance | undefined {
180
+ const localPlayer = Players.LocalPlayer;
181
+ if (!localPlayer) return undefined;
182
+ let playerScripts = localPlayer.FindFirstChild("PlayerScripts");
183
+ if (!playerScripts) {
184
+ playerScripts = localPlayer.WaitForChild("PlayerScripts", 5);
192
185
  }
193
- return installBridges();
186
+ return playerScripts;
187
+ }
188
+
189
+ function clientRuntimeBridgeReady(): boolean {
190
+ const playerScripts = getPlayerScripts();
191
+ if (!playerScripts) return false;
192
+ const scriptInst = playerScripts.FindFirstChild(CLIENT_SCRIPT_NAME);
193
+ const bindable = ReplicatedStorage.FindFirstChild(BRIDGE_NAMES.clientLocal);
194
+ return scriptInst !== undefined &&
195
+ scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
196
+ bindable !== undefined &&
197
+ bindable.IsA("BindableFunction");
194
198
  }
195
199
 
196
- export function installBridges(): { installed: boolean; error?: string } {
197
- // Defensive: clear any stale bridges from a prior unclean exit before
198
- // inserting fresh. The injected script also self-cleans its
199
- // ReplicatedStorage/ServerScriptService children at startup, but the
200
- // containing Script/LocalScript objects themselves we must clear here.
201
- cleanupBridges();
200
+ function installServerRuntimeBridge(): InstallResult {
201
+ if (serverRuntimeBridgeReady()) return { installed: true };
202
202
 
203
203
  const [ok, err] = pcall(() => {
204
+ destroyIfPresent(ServerScriptService, SERVER_SCRIPT_NAME);
205
+ destroyIfPresent(ServerScriptService, BRIDGE_NAMES.serverLocal);
206
+
204
207
  const serverScript = new Instance("Script");
205
208
  serverScript.Name = SERVER_SCRIPT_NAME;
206
- // Archivable=true so ExecutePlayModeAsync's deep-clone includes the
207
- // script. cleanupBridges() removes it from the edit DM when the
208
- // playtest ends.
209
+ serverScript.Archivable = false;
209
210
  setSource(serverScript, SERVER_BRIDGE_SOURCE);
210
211
  serverScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
211
212
  serverScript.Parent = ServerScriptService;
213
+ });
214
+
215
+ if (!ok) {
216
+ return { installed: false, error: tostring(err) };
217
+ }
218
+ return { installed: true };
219
+ }
220
+
221
+ function installClientRuntimeBridge(): InstallResult {
222
+ if (clientRuntimeBridgeReady()) return { installed: true };
223
+
224
+ const playerScripts = getPlayerScripts();
225
+ if (!playerScripts) {
226
+ return { installed: false, error: "Players.LocalPlayer.PlayerScripts not found - cannot install client eval bridge" };
227
+ }
228
+
229
+ const [ok, err] = pcall(() => {
230
+ destroyIfPresent(playerScripts, CLIENT_SCRIPT_NAME);
231
+ destroyIfPresent(ReplicatedStorage, BRIDGE_NAMES.clientLocal);
212
232
 
213
- const sps = getStarterPlayerScripts();
214
- if (!sps) {
215
- error("StarterPlayer.StarterPlayerScripts not found - cannot install client eval bridge");
216
- }
217
233
  const clientScript = new Instance("LocalScript");
218
234
  clientScript.Name = CLIENT_SCRIPT_NAME;
235
+ clientScript.Archivable = false;
219
236
  setSource(clientScript, CLIENT_BRIDGE_SOURCE);
220
237
  clientScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
221
- clientScript.Parent = sps;
238
+ clientScript.Parent = playerScripts;
222
239
  });
223
240
 
224
241
  if (!ok) {
@@ -226,3 +243,13 @@ export function installBridges(): { installed: boolean; error?: string } {
226
243
  }
227
244
  return { installed: true };
228
245
  }
246
+
247
+ export function ensureRuntimeBridgeInstalled(): InstallResult {
248
+ if (!RunService.IsRunning()) {
249
+ return { installed: false, error: "Eval bridges are installed only in running play DataModels" };
250
+ }
251
+ if (RunService.IsServer()) {
252
+ return installServerRuntimeBridge();
253
+ }
254
+ return installClientRuntimeBridge();
255
+ }
@@ -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
+ };