@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.
- package/dist/index.js +9401 -9047
- package/package.json +2 -2
- package/studio-plugin/MCPPlugin.rbxmx +400 -97
- package/studio-plugin/.Carbon.rbxm.lock +0 -0
- package/studio-plugin/Carbon.rbxm +0 -0
- package/studio-plugin/INSTALLATION.md +0 -170
- package/studio-plugin/MCPInspectorPlugin.rbxmx +0 -169759
- package/studio-plugin/default.project.json +0 -19
- package/studio-plugin/dev.project.json +0 -23
- package/studio-plugin/include/LibMP.lua +0 -156378
- package/studio-plugin/inspector-icon.png +0 -0
- package/studio-plugin/package-lock.json +0 -706
- package/studio-plugin/package.json +0 -19
- package/studio-plugin/plugin.json +0 -10
- package/studio-plugin/src/modules/AssetSanitizationPolicy.ts +0 -127
- package/studio-plugin/src/modules/ClientBroker.ts +0 -450
- package/studio-plugin/src/modules/Communication.ts +0 -601
- package/studio-plugin/src/modules/EvalBridges.ts +0 -255
- package/studio-plugin/src/modules/HttpDiagnostics.ts +0 -50
- package/studio-plugin/src/modules/LuauExec.ts +0 -403
- package/studio-plugin/src/modules/Recording.ts +0 -28
- package/studio-plugin/src/modules/RenderMonitor.ts +0 -60
- package/studio-plugin/src/modules/RuntimeLogBuffer.ts +0 -210
- package/studio-plugin/src/modules/ServerUrlSettings.ts +0 -117
- package/studio-plugin/src/modules/State.ts +0 -39
- package/studio-plugin/src/modules/StopPlayMonitor.ts +0 -267
- package/studio-plugin/src/modules/UI.ts +0 -597
- package/studio-plugin/src/modules/Utils.ts +0 -527
- package/studio-plugin/src/modules/handlers/AssetHandlers.ts +0 -391
- package/studio-plugin/src/modules/handlers/BreakpointHandlers.ts +0 -460
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +0 -170
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +0 -149
- package/studio-plugin/src/modules/handlers/GenerateModelHandlers.ts +0 -168
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +0 -163
- package/studio-plugin/src/modules/handlers/LogHandlers.ts +0 -14
- package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +0 -44
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +0 -96
- package/studio-plugin/src/modules/handlers/MicroProfilerHandlers.ts +0 -1263
- package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +0 -62
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +0 -716
- package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +0 -216
- package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +0 -531
- package/studio-plugin/src/modules/handlers/ScriptProfilerHandlers.ts +0 -386
- package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +0 -172
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +0 -350
- package/studio-plugin/src/server/index.server.ts +0 -135
- package/studio-plugin/src/types/index.d.ts +0 -57
- 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
|
-
};
|