@chrrxs/robloxstudio-mcp 3.0.0 → 3.0.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.
Files changed (48) hide show
  1. package/dist/index.js +9401 -9047
  2. package/package.json +2 -2
  3. package/studio-plugin/MCPPlugin.rbxmx +400 -97
  4. package/studio-plugin/.Carbon.rbxm.lock +0 -0
  5. package/studio-plugin/Carbon.rbxm +0 -0
  6. package/studio-plugin/INSTALLATION.md +0 -170
  7. package/studio-plugin/MCPInspectorPlugin.rbxmx +0 -169759
  8. package/studio-plugin/default.project.json +0 -19
  9. package/studio-plugin/dev.project.json +0 -23
  10. package/studio-plugin/include/LibMP.lua +0 -156378
  11. package/studio-plugin/inspector-icon.png +0 -0
  12. package/studio-plugin/package-lock.json +0 -706
  13. package/studio-plugin/package.json +0 -19
  14. package/studio-plugin/plugin.json +0 -10
  15. package/studio-plugin/src/modules/AssetSanitizationPolicy.ts +0 -127
  16. package/studio-plugin/src/modules/ClientBroker.ts +0 -450
  17. package/studio-plugin/src/modules/Communication.ts +0 -601
  18. package/studio-plugin/src/modules/EvalBridges.ts +0 -255
  19. package/studio-plugin/src/modules/HttpDiagnostics.ts +0 -50
  20. package/studio-plugin/src/modules/LuauExec.ts +0 -403
  21. package/studio-plugin/src/modules/Recording.ts +0 -28
  22. package/studio-plugin/src/modules/RenderMonitor.ts +0 -60
  23. package/studio-plugin/src/modules/RuntimeLogBuffer.ts +0 -210
  24. package/studio-plugin/src/modules/ServerUrlSettings.ts +0 -117
  25. package/studio-plugin/src/modules/State.ts +0 -39
  26. package/studio-plugin/src/modules/StopPlayMonitor.ts +0 -267
  27. package/studio-plugin/src/modules/UI.ts +0 -597
  28. package/studio-plugin/src/modules/Utils.ts +0 -527
  29. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +0 -391
  30. package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +0 -460
  31. package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +0 -170
  32. package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +0 -149
  33. package/studio-plugin/src/modules/handlers/GenerateModelHandlers.ts +0 -168
  34. package/studio-plugin/src/modules/handlers/InputHandlers.ts +0 -163
  35. package/studio-plugin/src/modules/handlers/LogHandlers.ts +0 -14
  36. package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +0 -44
  37. package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +0 -96
  38. package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +0 -1263
  39. package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +0 -62
  40. package/studio-plugin/src/modules/handlers/QueryHandlers.ts +0 -716
  41. package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +0 -216
  42. package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +0 -531
  43. package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +0 -386
  44. package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +0 -172
  45. package/studio-plugin/src/modules/handlers/TestHandlers.ts +0 -350
  46. package/studio-plugin/src/server/index.server.ts +0 -135
  47. package/studio-plugin/src/types/index.d.ts +0 -57
  48. package/studio-plugin/tsconfig.json +0 -20
@@ -1,601 +0,0 @@
1
- import { HttpService, RunService, ServerStorage } from "@rbxts/services";
2
- import State from "./State";
3
- import Utils from "./Utils";
4
- import UI from "./UI";
5
- import { cleanupLegacyEditBridges } from "./EvalBridges";
6
- import QueryHandlers from "./handlers/QueryHandlers";
7
- import PropertyHandlers from "./handlers/PropertyHandlers";
8
- import ScriptHandlers from "./handlers/ScriptHandlers";
9
- import MetadataHandlers from "./handlers/MetadataHandlers";
10
- import TestHandlers from "./handlers/TestHandlers";
11
- import AssetHandlers from "./handlers/AssetHandlers";
12
- import CaptureHandlers from "./handlers/CaptureHandlers";
13
- import InputHandlers from "./handlers/InputHandlers";
14
- import LogHandlers from "./handlers/LogHandlers";
15
- import SerializationHandlers from "./handlers/SerializationHandlers";
16
- import MemoryHandlers from "./handlers/MemoryHandlers";
17
- import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
18
- import BreakpointHandlers from "./handlers/BreakpointHandlers";
19
- import ScriptProfilerHandlers from "./handlers/ScriptProfilerHandlers";
20
- import MicroProfilerHandlers from "./handlers/MicroProfilerHandlers";
21
- import GenerateModelHandlers from "./handlers/GenerateModelHandlers";
22
- import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
23
- import ClientBroker from "./ClientBroker";
24
- import ServerUrlSettings from "./ServerUrlSettings";
25
- import HttpDiagnostics from "./HttpDiagnostics";
26
- import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
27
-
28
- // Per-plugin-load random GUID. Used as the /poll URL param so the server
29
- // can tell our polls apart from any other plugin's polls. Not user-facing —
30
- // MCP tools and the LLM operate on instanceId (the place identifier).
31
- const pluginSessionId = HttpService.GenerateGUID(false);
32
-
33
- // Place-level identifier shared by every plugin running in DataModels of
34
- // the same place file (edit DM + playtest server DM + playtest clients).
35
- // Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
36
- // places where the UUID lives on ServerStorage's __MCPPlaceId attribute
37
- // and travels with the .rbxl.
38
- const MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId";
39
-
40
- function computeInstanceId(): string {
41
- if (game.PlaceId !== 0) {
42
- return `place:${tostring(game.PlaceId)}`;
43
- }
44
- const existing = ServerStorage.GetAttribute(MCP_PLACE_ID_ATTRIBUTE);
45
- if (typeIs(existing, "string") && existing !== "") {
46
- return `anon:${existing as string}`;
47
- }
48
- const fresh = HttpService.GenerateGUID(false);
49
- pcall(() => ServerStorage.SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh));
50
- return `anon:${fresh}`;
51
- }
52
-
53
- let assignedRole: string | undefined;
54
- let hasVersionMismatch = false;
55
- let lastVersionMismatchWarningKey: string | undefined;
56
- let lastReadyInstanceId: string | undefined;
57
- const readyFailureLogKeys = new Set<string>();
58
- let retryingDuplicateReady = false;
59
-
60
- // Cache the published place name from MarketplaceService:GetProductInfo so
61
- // /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
62
- // from game.Name (the DataModel name, often "Place1" in edit). We only fetch
63
- // once per plugin load; the published name doesn't change mid-session.
64
- let cachedPlaceName: string | undefined;
65
- let cachedPlaceNamePlaceId: number | undefined;
66
-
67
- function resolvePlaceName(): string {
68
- if (cachedPlaceName !== undefined && cachedPlaceNamePlaceId === game.PlaceId) return cachedPlaceName;
69
- cachedPlaceName = undefined;
70
- cachedPlaceNamePlaceId = game.PlaceId;
71
- if (game.PlaceId === 0) {
72
- cachedPlaceName = game.Name;
73
- return cachedPlaceName;
74
- }
75
- const MarketplaceService = game.GetService("MarketplaceService");
76
- const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
77
- if (ok && info !== undefined) {
78
- const name = (info as { Name?: string }).Name;
79
- if (typeIs(name, "string") && name !== "") {
80
- cachedPlaceName = name;
81
- return cachedPlaceName;
82
- }
83
- }
84
- // Don't cache failures — could be transient (offline, rate-limited).
85
- // Next /ready will retry. Return game.Name as fallback.
86
- return game.Name;
87
- }
88
-
89
- function detectRole(): string {
90
- if (!RunService.IsRunning()) return "edit";
91
- if (RunService.IsServer()) return "server";
92
- return "client";
93
- }
94
-
95
- const initialRole = detectRole();
96
-
97
- type Handler = (data: Record<string, unknown>) => unknown;
98
-
99
- const routeMap: Record<string, Handler> = {
100
-
101
- "/api/file-tree": QueryHandlers.getFileTree,
102
- "/api/search-files": QueryHandlers.searchFiles,
103
- "/api/place-info": QueryHandlers.getPlaceInfo,
104
- "/api/search-objects": QueryHandlers.searchObjects,
105
- "/api/instance-properties": QueryHandlers.getInstanceProperties,
106
- "/api/search-by-property": QueryHandlers.searchByProperty,
107
- "/api/class-info": QueryHandlers.getClassInfo,
108
- "/api/project-structure": QueryHandlers.getProjectStructure,
109
- "/api/grep-scripts": QueryHandlers.grepScripts,
110
-
111
- "/api/set-properties": PropertyHandlers.setProperties,
112
-
113
- "/api/get-script-source": ScriptHandlers.getScriptSource,
114
- "/api/set-script-source": ScriptHandlers.setScriptSource,
115
- "/api/edit-script-lines": ScriptHandlers.editScriptLines,
116
- "/api/insert-script-lines": ScriptHandlers.insertScriptLines,
117
- "/api/delete-script-lines": ScriptHandlers.deleteScriptLines,
118
-
119
- "/api/get-attributes": MetadataHandlers.getAttributes,
120
- "/api/get-selection": MetadataHandlers.getSelection,
121
- "/api/execute-luau": MetadataHandlers.executeLuau,
122
- "/api/eval-runtime": EvalRuntimeHandlers.evalRuntime,
123
-
124
- "/api/start-playtest": TestHandlers.startPlaytest,
125
- "/api/stop-playtest": TestHandlers.stopPlaytest,
126
- "/api/multiplayer-test-start": TestHandlers.multiplayerTestStart,
127
- "/api/multiplayer-test-state": TestHandlers.multiplayerTestState,
128
- "/api/multiplayer-test-add-players": TestHandlers.multiplayerTestAddPlayers,
129
- "/api/multiplayer-test-leave-client": TestHandlers.multiplayerTestLeaveClient,
130
- "/api/multiplayer-test-end": TestHandlers.multiplayerTestEnd,
131
-
132
- "/api/insert-asset": AssetHandlers.insertAsset,
133
- "/api/preview-asset": AssetHandlers.previewAsset,
134
-
135
- "/api/capture-screenshot": CaptureHandlers.captureScreenshot,
136
- "/api/capture-begin": CaptureHandlers.captureBegin,
137
- "/api/capture-read": CaptureHandlers.captureRead,
138
- "/api/simulate-mouse-input": InputHandlers.simulateMouseInput,
139
- "/api/simulate-keyboard-input": InputHandlers.simulateKeyboardInput,
140
-
141
- "/api/find-and-replace-in-scripts": ScriptHandlers.findAndReplaceInScripts,
142
-
143
- "/api/get-runtime-logs": LogHandlers.getRuntimeLogs,
144
- "/api/breakpoints": BreakpointHandlers.breakpoints,
145
- "/api/capture-script-profiler": ScriptProfilerHandlers.captureScriptProfiler,
146
- "/api/capture-micro-profiler": MicroProfilerHandlers.captureMicroProfiler,
147
- "/api/generate-model": GenerateModelHandlers.generateModel,
148
-
149
- "/api/export-rbxm": SerializationHandlers.exportRbxm,
150
- "/api/import-rbxm": SerializationHandlers.importRbxm,
151
-
152
- "/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
153
- "/api/get-scene-analysis": SceneAnalysisHandlers.getSceneAnalysis,
154
- };
155
-
156
- function processRequest(request: RequestPayload): unknown {
157
- const endpoint = request.endpoint;
158
- const data = request.data ?? {};
159
-
160
- const handler = routeMap[endpoint];
161
- if (handler) {
162
- return handler(data as Record<string, unknown>);
163
- } else {
164
- return { error: `Unknown endpoint: ${endpoint}` };
165
- }
166
- }
167
-
168
- function sendResponse(conn: Connection, requestId: string, responseData: unknown) {
169
- const responseUrl = `${conn.serverUrl}/response`;
170
- const [encodeOk, encoded] = pcall(() => HttpService.JSONEncode({ requestId, response: responseData }));
171
- const body = encodeOk
172
- ? encoded
173
- : HttpService.JSONEncode({
174
- requestId,
175
- error: `Plugin response serialization failed: ${tostring(encoded)}`,
176
- });
177
- if (!encodeOk) {
178
- warn(`[robloxstudio-mcp] Failed to serialize response ${requestId}: ${tostring(encoded)}`);
179
- }
180
-
181
- const [requestOk, requestResult] = pcall(() => HttpService.RequestAsync({
182
- Url: responseUrl,
183
- Method: "POST",
184
- Headers: { "Content-Type": "application/json" },
185
- Body: body,
186
- }));
187
- if (!requestOk || !requestResult.Success) {
188
- warn(`[robloxstudio-mcp] Failed to deliver response ${requestId}: ${HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`);
189
- }
190
- }
191
-
192
- function getConnectionStatus(): string {
193
- const conn = State.getActiveConnection();
194
- if (!conn.isActive) return "disconnected";
195
- if (conn.consecutiveFailures >= conn.maxFailuresBeforeError) return "error";
196
- if (conn.lastHttpOk) return "connected";
197
- return "connecting";
198
- }
199
-
200
- // Throttle for re-issuing /ready after the server reports knownInstance=false.
201
- // Without this, every poll during the brief window where the server has just
202
- // restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
203
- let lastReadyPostAt = 0;
204
-
205
- // game.Name and game.PlaceId can both settle after plugin load. PlaceId also
206
- // changes when an unpublished file is published while MCP is already active.
207
- // Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
208
- let nameChangeConn: RBXScriptConnection | undefined;
209
- let placeIdChangeConn: RBXScriptConnection | undefined;
210
- function ensureIdentityWatcher(conn: Connection): void {
211
- if (!nameChangeConn) {
212
- const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
213
- if (okSig && signal) {
214
- nameChangeConn = signal.Connect(() => {
215
- // sendReady has its own 2s throttle, so rapid burst changes coalesce.
216
- sendReady(conn);
217
- });
218
- }
219
- }
220
- if (!placeIdChangeConn) {
221
- const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("PlaceId"));
222
- if (okSig && signal) {
223
- placeIdChangeConn = signal.Connect(() => {
224
- cachedPlaceName = undefined;
225
- cachedPlaceNamePlaceId = undefined;
226
- sendReady(conn);
227
- });
228
- }
229
- }
230
- }
231
-
232
- function sendReady(conn: Connection): void {
233
- const now = tick();
234
- // Normal identity refreshes stay conservatively throttled. Once a stale
235
- // predecessor causes 409, retry once per second so takeover follows the
236
- // server's short inactivity lease without another two seconds of jitter.
237
- const readyInterval = retryingDuplicateReady ? 1 : 2;
238
- if (now - lastReadyPostAt < readyInterval) return;
239
- lastReadyPostAt = now;
240
- const instanceId = computeInstanceId();
241
- task.spawn(() => {
242
- const [readyOk, readyResult] = pcall(() => {
243
- return HttpService.RequestAsync({
244
- Url: `${conn.serverUrl}/ready`,
245
- Method: "POST",
246
- Headers: { "Content-Type": "application/json" },
247
- Body: HttpService.JSONEncode({
248
- pluginSessionId,
249
- instanceId,
250
- role: detectRole(),
251
- placeId: game.PlaceId,
252
- placeName: resolvePlaceName(),
253
- dataModelName: game.Name,
254
- isRunning: RunService.IsRunning(),
255
- pluginVersion: State.CURRENT_VERSION,
256
- pluginVariant: State.PLUGIN_VARIANT,
257
- pluginReady: true,
258
- timestamp: tick(),
259
- }),
260
- });
261
- });
262
- const readyUrl = `${conn.serverUrl}/ready`;
263
- const readyRole = detectRole();
264
- const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
265
- if (!readyOk) {
266
- const shouldLog = !readyFailureLogKeys.has(readyLogKey);
267
- readyFailureLogKeys.add(readyLogKey);
268
- if (shouldLog) {
269
- warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
270
- }
271
- return;
272
- }
273
- if (!readyResult.Success) {
274
- const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
275
- const shouldLog = !readyFailureLogKeys.has(readyLogKey);
276
- readyFailureLogKeys.add(readyLogKey);
277
- // A predecessor can remain registered briefly when Studio exits before
278
- // its asynchronous /disconnect completes. Keep polling and retrying
279
- // /ready: the server will take us over once the predecessor has stopped
280
- // polling, while a genuinely active duplicate continues to hold routing.
281
- if (readyResult.StatusCode === 409) {
282
- retryingDuplicateReady = true;
283
- const ui = UI.getElements();
284
- ui.statusLabel.Text = "Waiting for previous instance";
285
- ui.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
286
- ui.detailStatusLabel.Text = reason;
287
- ui.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
288
- if (shouldLog) {
289
- warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
290
- }
291
- return;
292
- }
293
- if (shouldLog) {
294
- warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
295
- }
296
- return;
297
- }
298
- retryingDuplicateReady = false;
299
- const [parseOk, readyData] = pcall(
300
- () => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
301
- );
302
- if (parseOk && readyData.assignedRole) {
303
- assignedRole = readyData.assignedRole;
304
- }
305
- lastReadyInstanceId = parseOk && typeIs(readyData.instanceId, "string") && readyData.instanceId !== ""
306
- ? readyData.instanceId
307
- : instanceId;
308
- ServerUrlSettings.rememberServerUrl(conn.serverUrl);
309
- const connectedRole = assignedRole ?? detectRole();
310
- if (readyFailureLogKeys.has(readyLogKey)) {
311
- readyFailureLogKeys.delete(readyLogKey);
312
- print(`[robloxstudio-mcp] /ready connected for ${instanceId}/${connectedRole} via ${conn.serverUrl}`);
313
- }
314
- });
315
- }
316
-
317
- function pollForRequests() {
318
- const conn = State.getActiveConnection();
319
- if (!conn.isActive) return;
320
- if (conn.isPolling) return;
321
-
322
- conn.isPolling = true;
323
-
324
- const [success, result] = pcall(() => {
325
- return HttpService.RequestAsync({
326
- Url: `${conn.serverUrl}/poll?pluginSessionId=${pluginSessionId}`,
327
- Method: "GET",
328
- Headers: { "Content-Type": "application/json" },
329
- });
330
- });
331
-
332
- conn.isPolling = false;
333
-
334
- const ui = UI.getElements();
335
- UI.updateToolbarIcon();
336
-
337
- if (success && (result.Success || result.StatusCode === 503)) {
338
- conn.consecutiveFailures = 0;
339
- conn.currentRetryDelay = 0.5;
340
- conn.lastSuccessfulConnection = tick();
341
-
342
- const data = HttpService.JSONDecode(result.Body) as PollResponse;
343
- const mcpConnected = data.mcpConnected === true;
344
- conn.lastHttpOk = true;
345
- conn.lastMcpOk = mcpConnected;
346
- const serverVersion = data.serverVersion ?? "unknown";
347
- if (data.versionMismatch === true) {
348
- hasVersionMismatch = true;
349
- const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
350
- if (lastVersionMismatchWarningKey !== warningKey) {
351
- lastVersionMismatchWarningKey = warningKey;
352
- 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.`);
353
- }
354
- UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
355
- } else if (hasVersionMismatch) {
356
- hasVersionMismatch = false;
357
- UI.hideBanner("version-mismatch");
358
- }
359
-
360
- // Server tells us when its in-memory instances map doesn't have us
361
- // (e.g. after an MCP process restart). Re-issue /ready immediately so
362
- // target=server/client-N start routing again. The throttle inside
363
- // sendReady() prevents duplicate registrations while the server
364
- // catches up.
365
- if (data.knownInstance === false) {
366
- sendReady(conn);
367
- }
368
-
369
- const el = ui;
370
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
371
- el.step1Label.Text = "HTTP server (OK)";
372
-
373
- if (mcpConnected && !el.statusLabel.Text.find("Connected")[0]) {
374
- el.statusLabel.Text = "Connected";
375
- el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94);
376
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
377
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
378
- el.statusText.Text = "ONLINE";
379
- el.detailStatusLabel.Text = "HTTP: OK MCP: OK";
380
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94);
381
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
382
- el.step2Label.Text = "MCP bridge (OK)";
383
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
384
- el.step3Label.Text = "Commands (OK)";
385
- conn.mcpWaitStartTime = undefined;
386
- el.troubleshootLabel.Visible = false;
387
- UI.stopPulseAnimation();
388
- } else if (!mcpConnected) {
389
- el.statusLabel.Text = "Waiting for MCP server";
390
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
391
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
392
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
393
- el.statusText.Text = "WAITING";
394
- el.detailStatusLabel.Text = "HTTP: OK MCP: ...";
395
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
396
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
397
- el.step2Label.Text = "MCP bridge (waiting...)";
398
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
399
- el.step3Label.Text = "Commands (waiting...)";
400
- if (conn.mcpWaitStartTime === undefined) {
401
- conn.mcpWaitStartTime = tick();
402
- }
403
- const elapsed = tick() - (conn.mcpWaitStartTime ?? tick());
404
- el.troubleshootLabel.Visible = elapsed > 8;
405
- UI.startPulseAnimation();
406
- }
407
-
408
- if (data.request && mcpConnected) {
409
- task.spawn(() => {
410
- const [ok, response] = pcall(() => processRequest(data.request!));
411
- if (ok) {
412
- sendResponse(conn, data.requestId!, response);
413
- } else {
414
- sendResponse(conn, data.requestId!, { error: tostring(response) });
415
- }
416
- });
417
- }
418
- } else if (conn.isActive) {
419
- conn.consecutiveFailures++;
420
-
421
- if (conn.consecutiveFailures > 1) {
422
- conn.currentRetryDelay = math.min(
423
- conn.currentRetryDelay * conn.retryBackoffMultiplier,
424
- conn.maxRetryDelay,
425
- );
426
- }
427
-
428
-
429
- const el = ui;
430
- if (conn.consecutiveFailures >= conn.maxFailuresBeforeError) {
431
- el.statusLabel.Text = "Server unavailable";
432
- el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
433
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
434
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
435
- el.statusText.Text = "ERROR";
436
- el.detailStatusLabel.Text = "HTTP: X MCP: X";
437
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
438
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
439
- el.step1Label.Text = "HTTP server (error)";
440
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
441
- el.step2Label.Text = "MCP bridge (error)";
442
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
443
- el.step3Label.Text = "Commands (error)";
444
- conn.mcpWaitStartTime = undefined;
445
- el.troubleshootLabel.Visible = false;
446
- UI.stopPulseAnimation();
447
- } else if (conn.consecutiveFailures > 5) {
448
- const waitTime = math.ceil(conn.currentRetryDelay);
449
- el.statusLabel.Text = `Retrying (${waitTime}s)`;
450
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
451
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
452
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
453
- el.statusText.Text = "RETRY";
454
- el.detailStatusLabel.Text = "HTTP: ... MCP: ...";
455
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
456
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
457
- el.step1Label.Text = "HTTP server (retrying...)";
458
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
459
- el.step2Label.Text = "MCP bridge (retrying...)";
460
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
461
- el.step3Label.Text = "Commands (retrying...)";
462
- conn.mcpWaitStartTime = undefined;
463
- el.troubleshootLabel.Visible = false;
464
- UI.startPulseAnimation();
465
- } else if (conn.consecutiveFailures > 1) {
466
- el.statusLabel.Text = `Connecting (attempt ${conn.consecutiveFailures})`;
467
- el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
468
- el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
469
- el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
470
- el.statusText.Text = "CONNECTING";
471
- el.detailStatusLabel.Text = "HTTP: ... MCP: ...";
472
- el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
473
- el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
474
- el.step1Label.Text = "HTTP server (connecting...)";
475
- el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
476
- el.step2Label.Text = "MCP bridge (connecting...)";
477
- el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
478
- el.step3Label.Text = "Commands (connecting...)";
479
- conn.mcpWaitStartTime = undefined;
480
- el.troubleshootLabel.Visible = false;
481
- UI.startPulseAnimation();
482
- }
483
- }
484
- }
485
-
486
-
487
- function activatePlugin() {
488
- const conn = State.getActiveConnection();
489
- const ui = UI.getElements();
490
-
491
- conn.isActive = true;
492
- conn.consecutiveFailures = 0;
493
- conn.currentRetryDelay = 0.5;
494
-
495
- const normalizedUrl = ServerUrlSettings.normalizeServerUrl(ui.urlInput.Text);
496
- conn.serverUrl = normalizedUrl !== "" ? normalizedUrl : conn.serverUrl;
497
- if (conn.serverUrl === "") conn.serverUrl = ClientBroker.DEFAULT_MCP_URL;
498
- ui.urlInput.Text = conn.serverUrl;
499
- const port = ServerUrlSettings.extractPort(conn.serverUrl);
500
- if (port !== undefined) conn.port = port;
501
- UI.updateUIState();
502
-
503
- if (!conn.heartbeatConnection) {
504
- conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
505
- const now = tick();
506
- if (initialRole === "server" && !RunService.IsRunning()) {
507
- ClientBroker.disconnectAllProxies();
508
- deactivatePlugin();
509
- return;
510
- }
511
- const currentInstanceId = computeInstanceId();
512
- if (lastReadyInstanceId !== undefined && currentInstanceId !== lastReadyInstanceId) {
513
- cachedPlaceName = undefined;
514
- cachedPlaceNamePlaceId = undefined;
515
- sendReady(conn);
516
- }
517
- const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
518
- if (now - conn.lastPoll > currentInterval) {
519
- conn.lastPoll = now;
520
- pollForRequests();
521
- }
522
- });
523
- }
524
-
525
- // Initial /ready; pollForRequests will also re-fire ready if the server
526
- // later reports knownInstance=false (process restart, etc).
527
- sendReady(conn);
528
-
529
- // Remove legacy edit-mode eval bridge scripts from older plugin builds.
530
- // Current bridges are created only in running play DataModels.
531
- if (!RunService.IsRunning()) {
532
- task.spawn(cleanupLegacyEditBridges);
533
- }
534
-
535
- // Watch identity fields so stale name or anon instance ids are refreshed.
536
- ensureIdentityWatcher(conn);
537
- }
538
-
539
- function deactivatePlugin() {
540
- const conn = State.getActiveConnection();
541
- conn.isActive = false;
542
- conn.lastMcpOk = false;
543
-
544
- UI.updateUIState();
545
-
546
- pcall(() => {
547
- HttpService.RequestAsync({
548
- Url: `${conn.serverUrl}/disconnect`,
549
- Method: "POST",
550
- Headers: { "Content-Type": "application/json" },
551
- Body: HttpService.JSONEncode({ pluginSessionId, timestamp: tick() }),
552
- });
553
- });
554
-
555
- if (conn.heartbeatConnection) {
556
- conn.heartbeatConnection.Disconnect();
557
- conn.heartbeatConnection = undefined;
558
- }
559
-
560
- conn.consecutiveFailures = 0;
561
- conn.currentRetryDelay = 0.5;
562
- }
563
-
564
- function deactivateAll() {
565
- const conn = State.getActiveConnection();
566
- if (conn.isActive) {
567
- deactivatePlugin();
568
- }
569
- }
570
-
571
- function checkForUpdates() {
572
- task.spawn(() => {
573
- const [success, result] = pcall(() => {
574
- return HttpService.RequestAsync({
575
- Url: "https://registry.npmjs.org/@chrrxs/robloxstudio-mcp/latest",
576
- Method: "GET",
577
- Headers: { Accept: "application/json" },
578
- });
579
- });
580
-
581
- if (success && result.Success) {
582
- const [ok, data] = pcall(() => HttpService.JSONDecode(result.Body) as { version?: string });
583
- if (ok && data?.version) {
584
- const latestVersion = data.version;
585
- if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
586
- if (!hasVersionMismatch) {
587
- UI.showBanner("update", `v${latestVersion} available - github.com/chrrxs/robloxstudio-mcp`);
588
- }
589
- }
590
- }
591
- }
592
- });
593
- }
594
-
595
- export = {
596
- getConnectionStatus,
597
- activatePlugin,
598
- deactivatePlugin,
599
- deactivateAll,
600
- checkForUpdates,
601
- };