@chrrxs/robloxstudio-mcp-inspector 2.8.0

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 (30) hide show
  1. package/dist/index.js +4483 -0
  2. package/package.json +50 -0
  3. package/studio-plugin/INSTALLATION.md +150 -0
  4. package/studio-plugin/MCPInspectorPlugin.rbxmx +9074 -0
  5. package/studio-plugin/MCPPlugin.rbxmx +9074 -0
  6. package/studio-plugin/default.project.json +19 -0
  7. package/studio-plugin/dev.project.json +23 -0
  8. package/studio-plugin/inspector-icon.png +0 -0
  9. package/studio-plugin/package-lock.json +706 -0
  10. package/studio-plugin/package.json +19 -0
  11. package/studio-plugin/plugin.json +10 -0
  12. package/studio-plugin/src/modules/ClientBroker.ts +221 -0
  13. package/studio-plugin/src/modules/Communication.ts +399 -0
  14. package/studio-plugin/src/modules/Recording.ts +28 -0
  15. package/studio-plugin/src/modules/State.ts +94 -0
  16. package/studio-plugin/src/modules/UI.ts +725 -0
  17. package/studio-plugin/src/modules/Utils.ts +318 -0
  18. package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
  19. package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
  20. package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +128 -0
  21. package/studio-plugin/src/modules/handlers/InputHandlers.ts +102 -0
  22. package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
  23. package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +391 -0
  24. package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
  25. package/studio-plugin/src/modules/handlers/QueryHandlers.ts +827 -0
  26. package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
  27. package/studio-plugin/src/modules/handlers/TestHandlers.ts +277 -0
  28. package/studio-plugin/src/server/index.server.ts +63 -0
  29. package/studio-plugin/src/types/index.d.ts +44 -0
  30. package/studio-plugin/tsconfig.json +20 -0
@@ -0,0 +1,277 @@
1
+ import { HttpService, LogService } from "@rbxts/services";
2
+
3
+ const StudioTestService = game.GetService("StudioTestService");
4
+ const ServerScriptService = game.GetService("ServerScriptService");
5
+ const ScriptEditorService = game.GetService("ScriptEditorService");
6
+
7
+ const STOP_SIGNAL = "__MCP_STOP__";
8
+ const NAV_SIGNAL = "__MCP_NAV__";
9
+ const NAV_RESULT = "__MCP_NAV_RESULT__";
10
+
11
+ interface OutputEntry {
12
+ message: string;
13
+ messageType: string;
14
+ timestamp: number;
15
+ }
16
+
17
+ let testRunning = false;
18
+ let outputBuffer: OutputEntry[] = [];
19
+ let logConnection: RBXScriptConnection | undefined;
20
+ let testResult: unknown;
21
+ let testError: string | undefined;
22
+ let stopListenerScript: Script | undefined;
23
+ let navResultCallback: ((json: string) => void) | undefined;
24
+
25
+ function buildCommandListenerSource(): string {
26
+ return `local LogService = game:GetService("LogService")
27
+ local StudioTestService = game:GetService("StudioTestService")
28
+ local PathfindingService = game:GetService("PathfindingService")
29
+ local Players = game:GetService("Players")
30
+ local HttpService = game:GetService("HttpService")
31
+ local NAV_SIG = "${NAV_SIGNAL}"
32
+ local NAV_RES = "${NAV_RESULT}"
33
+ LogService.MessageOut:Connect(function(msg)
34
+ if msg == "${STOP_SIGNAL}" then
35
+ pcall(function() StudioTestService:EndTest("stopped_by_mcp") end)
36
+ elseif string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then
37
+ local json = string.sub(msg, #NAV_SIG + 2)
38
+ task.spawn(function()
39
+ local ok, d = pcall(function() return HttpService:JSONDecode(json) end)
40
+ if not ok or not d then
41
+ print(NAV_RES .. ':{"success":false,"error":"parse_error"}')
42
+ return
43
+ end
44
+ local ps = Players:GetPlayers()
45
+ if #ps == 0 then
46
+ print(NAV_RES .. ':{"success":false,"error":"no_players"}')
47
+ return
48
+ end
49
+ local char = ps[1].Character or ps[1].CharacterAdded:Wait()
50
+ local hum = char:FindFirstChildOfClass("Humanoid")
51
+ local root = char:FindFirstChild("HumanoidRootPart")
52
+ if not hum or not root then
53
+ print(NAV_RES .. ':{"success":false,"error":"no_humanoid"}')
54
+ return
55
+ end
56
+ local target
57
+ if d.instancePath then
58
+ local parts = string.split(d.instancePath, ".")
59
+ local cur = game
60
+ for i = 2, #parts do
61
+ cur = cur:FindFirstChild(parts[i])
62
+ if not cur then
63
+ print(NAV_RES .. ':{"success":false,"error":"instance_not_found"}')
64
+ return
65
+ end
66
+ end
67
+ if cur:IsA("BasePart") then target = cur.Position
68
+ elseif cur:IsA("Model") and cur.PrimaryPart then target = cur.PrimaryPart.Position
69
+ else target = cur:GetPivot().Position end
70
+ else
71
+ target = Vector3.new(d.x or 0, d.y or 0, d.z or 0)
72
+ end
73
+ local path = PathfindingService:CreatePath({AgentRadius=2,AgentHeight=5,AgentCanJump=true})
74
+ local pok = pcall(function() path:ComputeAsync(root.Position, target) end)
75
+ local method = "direct"
76
+ if pok and path.Status == Enum.PathStatus.Success then
77
+ method = "pathfinding"
78
+ for _, wp in ipairs(path:GetWaypoints()) do
79
+ hum:MoveTo(wp.Position)
80
+ if wp.Action == Enum.PathWaypointAction.Jump then hum.Jump = true end
81
+ hum.MoveToFinished:Wait()
82
+ end
83
+ else
84
+ hum:MoveTo(target)
85
+ hum.MoveToFinished:Wait()
86
+ end
87
+ local fp = root.Position
88
+ print(NAV_RES .. ':{"success":true,"method":"' .. method .. '","position":[' .. fp.X .. ',' .. fp.Y .. ',' .. fp.Z .. ']}')
89
+ end)
90
+ end
91
+ end)`;
92
+ }
93
+
94
+ function injectStopListener() {
95
+ const listener = new Instance("Script");
96
+ listener.Name = "__MCP_CommandListener";
97
+ listener.Parent = ServerScriptService;
98
+
99
+ const source = buildCommandListenerSource();
100
+ const [seOk] = pcall(() => {
101
+ ScriptEditorService.UpdateSourceAsync(listener, () => source);
102
+ });
103
+ if (!seOk) {
104
+ (listener as unknown as { Source: string }).Source = source;
105
+ }
106
+
107
+ stopListenerScript = listener;
108
+ }
109
+
110
+ function cleanupStopListener() {
111
+ if (stopListenerScript) {
112
+ pcall(() => stopListenerScript!.Destroy());
113
+ stopListenerScript = undefined;
114
+ }
115
+ }
116
+
117
+ function startPlaytest(requestData: Record<string, unknown>) {
118
+ const mode = requestData.mode as string | undefined;
119
+ const numPlayers = requestData.numPlayers as number | undefined;
120
+
121
+ if (mode !== "play" && mode !== "run") {
122
+ return { error: 'mode must be "play" or "run"' };
123
+ }
124
+
125
+ if (testRunning) {
126
+ return { error: "A test is already running" };
127
+ }
128
+
129
+ testRunning = true;
130
+ outputBuffer = [];
131
+ testResult = undefined;
132
+ testError = undefined;
133
+
134
+ cleanupStopListener();
135
+
136
+ logConnection = LogService.MessageOut.Connect((message, messageType) => {
137
+ if (message === STOP_SIGNAL) return;
138
+ if (message.sub(1, NAV_SIGNAL.size()) === NAV_SIGNAL) return;
139
+ if (message.sub(1, NAV_RESULT.size() + 1) === `${NAV_RESULT}:`) {
140
+ if (navResultCallback) {
141
+ navResultCallback(message.sub(NAV_RESULT.size() + 2));
142
+ }
143
+ return;
144
+ }
145
+ outputBuffer.push({
146
+ message,
147
+ messageType: messageType.Name,
148
+ timestamp: tick(),
149
+ });
150
+ });
151
+
152
+ const [injected, injErr] = pcall(() => injectStopListener());
153
+ if (!injected) {
154
+ warn(`[MCP] Failed to inject stop listener: ${injErr}`);
155
+ }
156
+
157
+ if (numPlayers !== undefined && mode === "run") {
158
+ const TestService = game.GetService("TestService") as TestService & { NumberOfPlayers: number };
159
+ TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8);
160
+ }
161
+
162
+ task.spawn(() => {
163
+ const [ok, result] = pcall(() => {
164
+ if (mode === "play") {
165
+ return StudioTestService.ExecutePlayModeAsync({});
166
+ }
167
+ return StudioTestService.ExecuteRunModeAsync({});
168
+ });
169
+
170
+ if (ok) {
171
+ testResult = result;
172
+ } else {
173
+ testError = tostring(result);
174
+ }
175
+
176
+ if (logConnection) {
177
+ logConnection.Disconnect();
178
+ logConnection = undefined;
179
+ }
180
+ testRunning = false;
181
+
182
+ cleanupStopListener();
183
+ });
184
+
185
+ const msg = numPlayers !== undefined
186
+ ? `Playtest started in ${mode} mode with ${numPlayers} player(s)`
187
+ : `Playtest started in ${mode} mode`;
188
+ return { success: true, message: msg };
189
+ }
190
+
191
+ function stopPlaytest(_requestData: Record<string, unknown>) {
192
+ // Stop requests are normally intercepted by the server-peer edit-proxy in
193
+ // modules/ClientBroker - that proxy runs inside the play server DM, the
194
+ // only DM where StudioTestService:EndTest is legal. If we reach this
195
+ // handler the broker either hasn't started yet or there's no active
196
+ // playtest. Try EndTest directly as a fallback (works for manually
197
+ // started playtests where the server-peer plugin happens to be polling).
198
+ const endTest = StudioTestService as unknown as Instance & { EndTest(reason: string): void };
199
+ const [endOk, endErr] = pcall(() => {
200
+ endTest.EndTest("stopped_by_mcp");
201
+ });
202
+ if (endOk) {
203
+ return {
204
+ success: true,
205
+ output: [...outputBuffer],
206
+ outputCount: outputBuffer.size(),
207
+ message: "Playtest stopped via StudioTestService.",
208
+ };
209
+ }
210
+
211
+ return {
212
+ error: `stopPlaytest fell through to edit DM (broker should have handled it). EndTest reported: ${tostring(endErr)}`,
213
+ };
214
+ }
215
+
216
+ function getPlaytestOutput(_requestData: Record<string, unknown>) {
217
+ return {
218
+ isRunning: testRunning,
219
+ output: [...outputBuffer],
220
+ outputCount: outputBuffer.size(),
221
+ testResult: testResult !== undefined ? tostring(testResult) : undefined,
222
+ testError,
223
+ };
224
+ }
225
+
226
+ function characterNavigation(requestData: Record<string, unknown>) {
227
+ if (!testRunning) {
228
+ return { error: "Playtest must be running. Start a playtest in 'play' mode first." };
229
+ }
230
+
231
+ const position = requestData.position as number[] | undefined;
232
+ const instancePath = requestData.instancePath as string | undefined;
233
+ const waitForCompletion = (requestData.waitForCompletion as boolean) ?? true;
234
+ const timeout = (requestData.timeout as number) ?? 25;
235
+
236
+ if (!position && !instancePath) {
237
+ return { error: "Either position [x, y, z] or instancePath is required" };
238
+ }
239
+
240
+ let navData: string;
241
+ if (position) {
242
+ navData = HttpService.JSONEncode({ x: position[0], y: position[1], z: position[2] });
243
+ } else {
244
+ navData = HttpService.JSONEncode({ instancePath });
245
+ }
246
+
247
+ warn(`${NAV_SIGNAL}:${navData}`);
248
+
249
+ if (!waitForCompletion) {
250
+ return { success: true, message: "Navigation command sent" };
251
+ }
252
+
253
+ let result: string | undefined;
254
+ navResultCallback = (json: string) => {
255
+ result = json;
256
+ };
257
+
258
+ const startTime = tick();
259
+ while (!result && tick() - startTime < timeout) {
260
+ task.wait(0.2);
261
+ }
262
+ navResultCallback = undefined;
263
+
264
+ if (result) {
265
+ const [ok, parsed] = pcall(() => HttpService.JSONDecode(result!));
266
+ if (ok) return parsed;
267
+ return { success: true, rawResult: result };
268
+ }
269
+ return { error: `Navigation timed out after ${timeout} seconds` };
270
+ }
271
+
272
+ export = {
273
+ startPlaytest,
274
+ stopPlaytest,
275
+ getPlaytestOutput,
276
+ characterNavigation,
277
+ };
@@ -0,0 +1,63 @@
1
+ import State from "../modules/State";
2
+ import UI from "../modules/UI";
3
+ import Communication from "../modules/Communication";
4
+ import ClientBroker from "../modules/ClientBroker";
5
+
6
+
7
+ UI.init(plugin);
8
+ const elements = UI.getElements();
9
+
10
+
11
+ const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
12
+ const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", "rbxassetid://__BUTTON_ICON_ID__");
13
+
14
+
15
+ elements.connectButton.Activated.Connect(() => {
16
+ const conn = State.getActiveConnection();
17
+ if (conn && conn.isActive) {
18
+ Communication.deactivatePlugin(State.getActiveTabIndex());
19
+ } else {
20
+ Communication.activatePlugin(State.getActiveTabIndex());
21
+ }
22
+ });
23
+
24
+
25
+ button.Click.Connect(() => {
26
+ elements.screenGui.Enabled = !elements.screenGui.Enabled;
27
+ });
28
+
29
+
30
+ plugin.Unloading.Connect(() => {
31
+ Communication.deactivateAll();
32
+ });
33
+
34
+
35
+ UI.updateUIState();
36
+ Communication.checkForUpdates();
37
+
38
+ // Auto-activate per peer. The boshyxd plugin only registers with MCP when the
39
+ // user clicks Connect in its UI, but that UI is invisible in play DMs - so
40
+ // play peers' plugin instances load without ever registering. Run after a
41
+ // short delay so the UI/State have a chance to initialize first.
42
+ task.delay(2, () => {
43
+ const role = ClientBroker.forkRole();
44
+ if (role === "edit" || role === "server") {
45
+ pcall(() => {
46
+ const idx = State.getActiveTabIndex();
47
+ const conn = State.getConnection(idx);
48
+ if (conn && !conn.isActive) {
49
+ // Defensive default: in invisible play-DM UIs, the input field
50
+ // may not be populated by the time we activate.
51
+ if (conn.serverUrl === undefined || conn.serverUrl === "") {
52
+ conn.serverUrl = ClientBroker.MCP_URL;
53
+ }
54
+ Communication.activatePlugin(idx);
55
+ }
56
+ });
57
+ }
58
+ if (role === "server") {
59
+ ClientBroker.setupServerBroker();
60
+ } else if (role === "client") {
61
+ ClientBroker.setupClientBroker();
62
+ }
63
+ });
@@ -0,0 +1,44 @@
1
+ /// <reference types="@rbxts/types/plugin" />
2
+
3
+ export interface Connection {
4
+ port: number;
5
+ serverUrl: string;
6
+ isActive: boolean;
7
+ pollInterval: number;
8
+ lastPoll: number;
9
+ consecutiveFailures: number;
10
+ maxFailuresBeforeError: number;
11
+ lastSuccessfulConnection: number;
12
+ currentRetryDelay: number;
13
+ maxRetryDelay: number;
14
+ retryBackoffMultiplier: number;
15
+ lastHttpOk: boolean;
16
+ lastMcpOk: boolean;
17
+ mcpWaitStartTime?: number;
18
+ isPolling: boolean;
19
+ heartbeatConnection?: RBXScriptConnection;
20
+ }
21
+
22
+ export interface RequestData {
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export interface RequestPayload {
27
+ endpoint: string;
28
+ data?: RequestData;
29
+ }
30
+
31
+ export interface PollResponse {
32
+ mcpConnected: boolean;
33
+ request?: RequestPayload;
34
+ requestId?: string;
35
+ }
36
+
37
+ export interface ReadyResponse {
38
+ success: boolean;
39
+ assignedRole?: string;
40
+ }
41
+
42
+ declare global {
43
+ function loadstring(code: string): LuaTuple<[(() => unknown) | undefined, string?]>;
44
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowSyntheticDefaultImports": true,
4
+ "downlevelIteration": true,
5
+ "jsx": "react",
6
+ "jsxFactory": "React.createElement",
7
+ "jsxFragmentFactory": "React.Fragment",
8
+ "module": "commonjs",
9
+ "moduleDetection": "force",
10
+ "moduleResolution": "node",
11
+ "noLib": true,
12
+ "resolveJsonModule": true,
13
+ "strict": true,
14
+ "target": "ESNext",
15
+ "typeRoots": ["node_modules/@rbxts"],
16
+ "rootDir": "src",
17
+ "outDir": "out",
18
+ "declaration": false
19
+ }
20
+ }