@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.
- package/dist/index.js +4483 -0
- package/package.json +50 -0
- package/studio-plugin/INSTALLATION.md +150 -0
- package/studio-plugin/MCPInspectorPlugin.rbxmx +9074 -0
- package/studio-plugin/MCPPlugin.rbxmx +9074 -0
- package/studio-plugin/default.project.json +19 -0
- package/studio-plugin/dev.project.json +23 -0
- package/studio-plugin/inspector-icon.png +0 -0
- package/studio-plugin/package-lock.json +706 -0
- package/studio-plugin/package.json +19 -0
- package/studio-plugin/plugin.json +10 -0
- package/studio-plugin/src/modules/ClientBroker.ts +221 -0
- package/studio-plugin/src/modules/Communication.ts +399 -0
- package/studio-plugin/src/modules/Recording.ts +28 -0
- package/studio-plugin/src/modules/State.ts +94 -0
- package/studio-plugin/src/modules/UI.ts +725 -0
- package/studio-plugin/src/modules/Utils.ts +318 -0
- package/studio-plugin/src/modules/handlers/AssetHandlers.ts +241 -0
- package/studio-plugin/src/modules/handlers/BuildHandlers.ts +481 -0
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +128 -0
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +102 -0
- package/studio-plugin/src/modules/handlers/InstanceHandlers.ts +380 -0
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +391 -0
- package/studio-plugin/src/modules/handlers/PropertyHandlers.ts +191 -0
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +827 -0
- package/studio-plugin/src/modules/handlers/ScriptHandlers.ts +530 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +277 -0
- package/studio-plugin/src/server/index.server.ts +63 -0
- package/studio-plugin/src/types/index.d.ts +44 -0
- 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
|
+
}
|