@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,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "studio-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "rbxtsc",
|
|
7
|
+
"watch": "rbxtsc -w",
|
|
8
|
+
"dev": "rbxtsc -w --project tsconfig.json"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@rbxts/compiler-types": "^3.0.0-types.0",
|
|
12
|
+
"@rbxts/types": "^1.0.906",
|
|
13
|
+
"roblox-ts": "^3.0.0",
|
|
14
|
+
"typescript": "^5.9.3"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@rbxts/services": "^1.6.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { HttpService, Players, ReplicatedStorage, RunService } from "@rbxts/services";
|
|
2
|
+
|
|
3
|
+
// The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
4
|
+
// HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
5
|
+
// HttpEnabled reads as false there regardless of identity. So the server peer
|
|
6
|
+
// brokers execute_luau requests to the client via a RemoteFunction it places
|
|
7
|
+
// in ReplicatedStorage; each player gets a proxy "client" registration on the
|
|
8
|
+
// MCP side, polled and dispatched by the server peer.
|
|
9
|
+
//
|
|
10
|
+
// The same server peer also registers an "edit" proxy that intercepts
|
|
11
|
+
// /api/stop-playtest specifically - StudioTestService:EndTest only works from
|
|
12
|
+
// the play server DM, so the real edit DM cannot satisfy stop requests on its
|
|
13
|
+
// own. MCP returns the same pending request to multiple pollers until someone
|
|
14
|
+
// /responds, so non-stop edit-targeted requests fall through to the actual
|
|
15
|
+
// edit DM untouched.
|
|
16
|
+
|
|
17
|
+
const MCP_URL = "http://localhost:58741";
|
|
18
|
+
const BROKER_NAME = "__MCPClientBroker";
|
|
19
|
+
|
|
20
|
+
interface ProxyEntry {
|
|
21
|
+
instanceId: string;
|
|
22
|
+
role: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ExecutePayload {
|
|
26
|
+
code?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ExecuteResult {
|
|
30
|
+
success: boolean;
|
|
31
|
+
returnValue?: string;
|
|
32
|
+
message?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ReadyResponseBody {
|
|
37
|
+
assignedRole?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PollResponseBody {
|
|
41
|
+
requestId?: string;
|
|
42
|
+
request?: {
|
|
43
|
+
endpoint: string;
|
|
44
|
+
data?: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function forkRole(): "edit" | "server" | "client" {
|
|
49
|
+
if (!RunService.IsRunning()) return "edit";
|
|
50
|
+
if (RunService.IsServer()) return "server";
|
|
51
|
+
return "client";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
55
|
+
return pcall(() =>
|
|
56
|
+
HttpService.RequestAsync({
|
|
57
|
+
Url: `${MCP_URL}${endpoint}`,
|
|
58
|
+
Method: "POST",
|
|
59
|
+
Headers: { "Content-Type": "application/json" },
|
|
60
|
+
Body: HttpService.JSONEncode(body),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setupClientBroker() {
|
|
66
|
+
const rf = ReplicatedStorage.WaitForChild(BROKER_NAME, 10);
|
|
67
|
+
if (!rf || !rf.IsA("RemoteFunction")) {
|
|
68
|
+
warn(`[MCPFork] client: ${BROKER_NAME} not found`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
rf.OnClientInvoke = (payload: ExecutePayload | undefined) => {
|
|
72
|
+
const code = payload && payload.code;
|
|
73
|
+
if (typeIs(code, "string") === false || code === "") {
|
|
74
|
+
return identity<ExecuteResult>({ success: false, error: "code is required" });
|
|
75
|
+
}
|
|
76
|
+
const m = new Instance("ModuleScript");
|
|
77
|
+
m.Name = "__MCPClientEval";
|
|
78
|
+
const [okSet, setErr] = pcall(() => {
|
|
79
|
+
(m as unknown as { Source: string }).Source = code as string;
|
|
80
|
+
});
|
|
81
|
+
if (!okSet) {
|
|
82
|
+
m.Destroy();
|
|
83
|
+
return identity<ExecuteResult>({ success: false, error: `Source set failed: ${tostring(setErr)}` });
|
|
84
|
+
}
|
|
85
|
+
m.Parent = game.Workspace;
|
|
86
|
+
const [okReq, result] = pcall(() => require(m));
|
|
87
|
+
m.Destroy();
|
|
88
|
+
if (okReq) {
|
|
89
|
+
return identity<ExecuteResult>({
|
|
90
|
+
success: true,
|
|
91
|
+
returnValue: result !== undefined ? tostring(result) : undefined,
|
|
92
|
+
message: "Code executed successfully",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return identity<ExecuteResult>({ success: false, error: tostring(result) });
|
|
96
|
+
};
|
|
97
|
+
print("[MCPFork] client broker ready");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const proxyByPlayer = new Map<Player, ProxyEntry>();
|
|
101
|
+
|
|
102
|
+
function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
103
|
+
while (player.Parent !== undefined && proxyByPlayer.has(player)) {
|
|
104
|
+
const [ok, res] = pcall(() =>
|
|
105
|
+
HttpService.RequestAsync({
|
|
106
|
+
Url: `${MCP_URL}/poll?instanceId=${proxyId}`,
|
|
107
|
+
Method: "GET",
|
|
108
|
+
Headers: { "Content-Type": "application/json" },
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
if (ok && res && (res.Success || res.StatusCode === 503)) {
|
|
112
|
+
const [okJson, body] = pcall(() => HttpService.JSONDecode(res.Body) as PollResponseBody);
|
|
113
|
+
if (okJson && body && body.request && body.requestId !== undefined) {
|
|
114
|
+
const request = body.request;
|
|
115
|
+
let response: unknown;
|
|
116
|
+
if (request.endpoint === "/api/execute-luau") {
|
|
117
|
+
const [okInvoke, invokeRes] = pcall(() => rf.InvokeClient(player, request.data));
|
|
118
|
+
if (okInvoke) {
|
|
119
|
+
response = invokeRes !== undefined ? invokeRes : { success: false, error: "nil response" };
|
|
120
|
+
} else {
|
|
121
|
+
response = { success: false, error: `InvokeClient failed: ${tostring(invokeRes)}` };
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
response = { error: `Client-proxy only supports /api/execute-luau, got: ${tostring(request.endpoint)}` };
|
|
125
|
+
}
|
|
126
|
+
postJson("/response", { requestId: body.requestId, response });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
task.wait(0.5);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function registerProxy(player: Player, rf: RemoteFunction) {
|
|
134
|
+
if (proxyByPlayer.has(player)) return;
|
|
135
|
+
const proxyId = HttpService.GenerateGUID(false);
|
|
136
|
+
const [ok, res] = postJson("/ready", { instanceId: proxyId, role: "client" });
|
|
137
|
+
if (!ok || !res || !res.Success) {
|
|
138
|
+
warn(`[MCPFork] proxy register failed for ${player.Name}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
|
|
142
|
+
const assigned = body.assignedRole ?? "client";
|
|
143
|
+
proxyByPlayer.set(player, { instanceId: proxyId, role: assigned });
|
|
144
|
+
print(`[MCPFork] proxy ${assigned} -> ${player.Name}`);
|
|
145
|
+
task.spawn(pollProxy, proxyId, player, rf);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function startEditProxyLoop() {
|
|
149
|
+
task.spawn(() => {
|
|
150
|
+
const proxyId = HttpService.GenerateGUID(false);
|
|
151
|
+
const [ok, res] = postJson("/ready", { instanceId: proxyId, role: "edit" });
|
|
152
|
+
if (!ok || !res || !res.Success) {
|
|
153
|
+
warn("[MCPFork] edit-proxy register failed");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
print("[MCPFork] edit-proxy ready (stop-playtest interceptor)");
|
|
157
|
+
while (true) {
|
|
158
|
+
const [okPoll, pollRes] = pcall(() =>
|
|
159
|
+
HttpService.RequestAsync({
|
|
160
|
+
Url: `${MCP_URL}/poll?instanceId=${proxyId}`,
|
|
161
|
+
Method: "GET",
|
|
162
|
+
Headers: { "Content-Type": "application/json" },
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
if (okPoll && pollRes && (pollRes.Success || pollRes.StatusCode === 503)) {
|
|
166
|
+
const [okJson, body] = pcall(() => HttpService.JSONDecode(pollRes.Body) as PollResponseBody);
|
|
167
|
+
if (
|
|
168
|
+
okJson &&
|
|
169
|
+
body &&
|
|
170
|
+
body.request &&
|
|
171
|
+
body.request.endpoint === "/api/stop-playtest" &&
|
|
172
|
+
body.requestId !== undefined
|
|
173
|
+
) {
|
|
174
|
+
const sts = game.GetService("StudioTestService") as Instance & { EndTest(reason: string): void };
|
|
175
|
+
const [endOk, endErr] = pcall(() => sts.EndTest("stopped_by_mcp"));
|
|
176
|
+
const response = endOk
|
|
177
|
+
? { success: true, message: "Playtest stopped via edit-proxy/EndTest" }
|
|
178
|
+
: { success: false, error: `EndTest failed: ${tostring(endErr)}` };
|
|
179
|
+
postJson("/response", { requestId: body.requestId, response });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
task.wait(0.15);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function setupServerBroker() {
|
|
188
|
+
let rf = ReplicatedStorage.FindFirstChild(BROKER_NAME) as RemoteFunction | undefined;
|
|
189
|
+
if (!rf) {
|
|
190
|
+
rf = new Instance("RemoteFunction");
|
|
191
|
+
rf.Name = BROKER_NAME;
|
|
192
|
+
rf.Parent = ReplicatedStorage;
|
|
193
|
+
}
|
|
194
|
+
const broker = rf;
|
|
195
|
+
Players.PlayerAdded.Connect((p) => registerProxy(p, broker));
|
|
196
|
+
for (const p of Players.GetPlayers()) {
|
|
197
|
+
task.spawn(registerProxy, p, broker);
|
|
198
|
+
}
|
|
199
|
+
Players.PlayerRemoving.Connect((p) => {
|
|
200
|
+
const entry = proxyByPlayer.get(p);
|
|
201
|
+
if (entry) {
|
|
202
|
+
proxyByPlayer.delete(p);
|
|
203
|
+
postJson("/disconnect", { instanceId: entry.instanceId });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
game.BindToClose(() => {
|
|
207
|
+
for (const [, entry] of proxyByPlayer) {
|
|
208
|
+
postJson("/disconnect", { instanceId: entry.instanceId });
|
|
209
|
+
}
|
|
210
|
+
proxyByPlayer.clear();
|
|
211
|
+
});
|
|
212
|
+
startEditProxyLoop();
|
|
213
|
+
print("[MCPFork] server broker ready");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export = {
|
|
217
|
+
MCP_URL,
|
|
218
|
+
forkRole,
|
|
219
|
+
setupClientBroker,
|
|
220
|
+
setupServerBroker,
|
|
221
|
+
};
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { HttpService, RunService } from "@rbxts/services";
|
|
2
|
+
import State from "./State";
|
|
3
|
+
import Utils from "./Utils";
|
|
4
|
+
import UI from "./UI";
|
|
5
|
+
import QueryHandlers from "./handlers/QueryHandlers";
|
|
6
|
+
import PropertyHandlers from "./handlers/PropertyHandlers";
|
|
7
|
+
import InstanceHandlers from "./handlers/InstanceHandlers";
|
|
8
|
+
import ScriptHandlers from "./handlers/ScriptHandlers";
|
|
9
|
+
import MetadataHandlers from "./handlers/MetadataHandlers";
|
|
10
|
+
import TestHandlers from "./handlers/TestHandlers";
|
|
11
|
+
import BuildHandlers from "./handlers/BuildHandlers";
|
|
12
|
+
import AssetHandlers from "./handlers/AssetHandlers";
|
|
13
|
+
import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
14
|
+
import InputHandlers from "./handlers/InputHandlers";
|
|
15
|
+
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
16
|
+
|
|
17
|
+
const instanceId = HttpService.GenerateGUID(false);
|
|
18
|
+
let assignedRole: string | undefined;
|
|
19
|
+
|
|
20
|
+
function detectRole(): string {
|
|
21
|
+
if (!RunService.IsRunning()) return "edit";
|
|
22
|
+
if (RunService.IsServer()) return "server";
|
|
23
|
+
return "client";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Handler = (data: Record<string, unknown>) => unknown;
|
|
27
|
+
|
|
28
|
+
const routeMap: Record<string, Handler> = {
|
|
29
|
+
|
|
30
|
+
"/api/file-tree": QueryHandlers.getFileTree,
|
|
31
|
+
"/api/search-files": QueryHandlers.searchFiles,
|
|
32
|
+
"/api/place-info": QueryHandlers.getPlaceInfo,
|
|
33
|
+
"/api/services": QueryHandlers.getServices,
|
|
34
|
+
"/api/search-objects": QueryHandlers.searchObjects,
|
|
35
|
+
"/api/instance-properties": QueryHandlers.getInstanceProperties,
|
|
36
|
+
"/api/instance-children": QueryHandlers.getInstanceChildren,
|
|
37
|
+
"/api/search-by-property": QueryHandlers.searchByProperty,
|
|
38
|
+
"/api/class-info": QueryHandlers.getClassInfo,
|
|
39
|
+
"/api/project-structure": QueryHandlers.getProjectStructure,
|
|
40
|
+
"/api/grep-scripts": QueryHandlers.grepScripts,
|
|
41
|
+
"/api/get-descendants": QueryHandlers.getDescendants,
|
|
42
|
+
"/api/compare-instances": QueryHandlers.compareInstances,
|
|
43
|
+
"/api/get-output-log": QueryHandlers.getOutputLog,
|
|
44
|
+
|
|
45
|
+
"/api/set-property": PropertyHandlers.setProperty,
|
|
46
|
+
"/api/set-properties": PropertyHandlers.setProperties,
|
|
47
|
+
"/api/mass-set-property": PropertyHandlers.massSetProperty,
|
|
48
|
+
"/api/mass-get-property": PropertyHandlers.massGetProperty,
|
|
49
|
+
"/api/create-object": InstanceHandlers.createObject,
|
|
50
|
+
"/api/mass-create-objects": InstanceHandlers.massCreateObjects,
|
|
51
|
+
// Back-compat alias: pre-2.7.0 servers split this endpoint when properties were present.
|
|
52
|
+
"/api/mass-create-objects-with-properties": InstanceHandlers.massCreateObjects,
|
|
53
|
+
"/api/delete-object": InstanceHandlers.deleteObject,
|
|
54
|
+
"/api/smart-duplicate": InstanceHandlers.smartDuplicate,
|
|
55
|
+
"/api/mass-duplicate": InstanceHandlers.massDuplicate,
|
|
56
|
+
"/api/clone-object": InstanceHandlers.cloneObject,
|
|
57
|
+
|
|
58
|
+
"/api/get-script-source": ScriptHandlers.getScriptSource,
|
|
59
|
+
"/api/set-script-source": ScriptHandlers.setScriptSource,
|
|
60
|
+
"/api/edit-script-lines": ScriptHandlers.editScriptLines,
|
|
61
|
+
"/api/insert-script-lines": ScriptHandlers.insertScriptLines,
|
|
62
|
+
"/api/delete-script-lines": ScriptHandlers.deleteScriptLines,
|
|
63
|
+
|
|
64
|
+
"/api/set-attribute": MetadataHandlers.setAttribute,
|
|
65
|
+
"/api/get-attributes": MetadataHandlers.getAttributes,
|
|
66
|
+
"/api/delete-attribute": MetadataHandlers.deleteAttribute,
|
|
67
|
+
"/api/get-tags": MetadataHandlers.getTags,
|
|
68
|
+
"/api/add-tag": MetadataHandlers.addTag,
|
|
69
|
+
"/api/remove-tag": MetadataHandlers.removeTag,
|
|
70
|
+
"/api/get-tagged": MetadataHandlers.getTagged,
|
|
71
|
+
"/api/get-selection": MetadataHandlers.getSelection,
|
|
72
|
+
"/api/execute-luau": MetadataHandlers.executeLuau,
|
|
73
|
+
"/api/undo": MetadataHandlers.undo,
|
|
74
|
+
"/api/redo": MetadataHandlers.redo,
|
|
75
|
+
"/api/bulk-set-attributes": MetadataHandlers.bulkSetAttributes,
|
|
76
|
+
|
|
77
|
+
"/api/start-playtest": TestHandlers.startPlaytest,
|
|
78
|
+
"/api/stop-playtest": TestHandlers.stopPlaytest,
|
|
79
|
+
"/api/get-playtest-output": TestHandlers.getPlaytestOutput,
|
|
80
|
+
"/api/character-navigation": TestHandlers.characterNavigation,
|
|
81
|
+
|
|
82
|
+
"/api/export-build": BuildHandlers.exportBuild,
|
|
83
|
+
"/api/import-build": BuildHandlers.importBuild,
|
|
84
|
+
"/api/import-scene": BuildHandlers.importScene,
|
|
85
|
+
"/api/search-materials": BuildHandlers.searchMaterials,
|
|
86
|
+
|
|
87
|
+
"/api/insert-asset": AssetHandlers.insertAsset,
|
|
88
|
+
"/api/preview-asset": AssetHandlers.previewAsset,
|
|
89
|
+
|
|
90
|
+
"/api/capture-screenshot": CaptureHandlers.captureScreenshot,
|
|
91
|
+
"/api/simulate-mouse-input": InputHandlers.simulateMouseInput,
|
|
92
|
+
"/api/simulate-keyboard-input": InputHandlers.simulateKeyboardInput,
|
|
93
|
+
|
|
94
|
+
"/api/find-and-replace-in-scripts": ScriptHandlers.findAndReplaceInScripts,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function processRequest(request: RequestPayload): unknown {
|
|
98
|
+
const endpoint = request.endpoint;
|
|
99
|
+
const data = request.data ?? {};
|
|
100
|
+
|
|
101
|
+
const handler = routeMap[endpoint];
|
|
102
|
+
if (handler) {
|
|
103
|
+
return handler(data as Record<string, unknown>);
|
|
104
|
+
} else {
|
|
105
|
+
return { error: `Unknown endpoint: ${endpoint}` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sendResponse(conn: Connection, requestId: string, responseData: unknown) {
|
|
110
|
+
pcall(() => {
|
|
111
|
+
HttpService.RequestAsync({
|
|
112
|
+
Url: `${conn.serverUrl}/response`,
|
|
113
|
+
Method: "POST",
|
|
114
|
+
Headers: { "Content-Type": "application/json" },
|
|
115
|
+
Body: HttpService.JSONEncode({ requestId, response: responseData }),
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getConnectionStatus(connIndex: number): string {
|
|
121
|
+
const conn = State.getConnection(connIndex);
|
|
122
|
+
if (!conn || !conn.isActive) return "disconnected";
|
|
123
|
+
if (conn.consecutiveFailures >= conn.maxFailuresBeforeError) return "error";
|
|
124
|
+
if (conn.lastHttpOk) return "connected";
|
|
125
|
+
return "connecting";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pollForRequests(connIndex: number) {
|
|
129
|
+
const conn = State.getConnection(connIndex);
|
|
130
|
+
if (!conn || !conn.isActive) return;
|
|
131
|
+
if (conn.isPolling) return;
|
|
132
|
+
|
|
133
|
+
conn.isPolling = true;
|
|
134
|
+
|
|
135
|
+
const [success, result] = pcall(() => {
|
|
136
|
+
return HttpService.RequestAsync({
|
|
137
|
+
Url: `${conn.serverUrl}/poll?instanceId=${instanceId}`,
|
|
138
|
+
Method: "GET",
|
|
139
|
+
Headers: { "Content-Type": "application/json" },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
conn.isPolling = false;
|
|
144
|
+
|
|
145
|
+
const ui = UI.getElements();
|
|
146
|
+
UI.updateTabDot(connIndex);
|
|
147
|
+
|
|
148
|
+
if (success && (result.Success || result.StatusCode === 503)) {
|
|
149
|
+
conn.consecutiveFailures = 0;
|
|
150
|
+
conn.currentRetryDelay = 0.5;
|
|
151
|
+
conn.lastSuccessfulConnection = tick();
|
|
152
|
+
|
|
153
|
+
const data = HttpService.JSONDecode(result.Body) as PollResponse;
|
|
154
|
+
const mcpConnected = data.mcpConnected === true;
|
|
155
|
+
conn.lastHttpOk = true;
|
|
156
|
+
conn.lastMcpOk = mcpConnected;
|
|
157
|
+
|
|
158
|
+
if (connIndex === State.getActiveTabIndex()) {
|
|
159
|
+
const el = ui;
|
|
160
|
+
el.step1Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
|
|
161
|
+
el.step1Label.Text = "HTTP server (OK)";
|
|
162
|
+
|
|
163
|
+
if (mcpConnected && !el.statusLabel.Text.find("Connected")[0]) {
|
|
164
|
+
el.statusLabel.Text = "Connected";
|
|
165
|
+
el.statusLabel.TextColor3 = Color3.fromRGB(34, 197, 94);
|
|
166
|
+
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
|
|
167
|
+
el.statusPulse.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
|
|
168
|
+
el.statusText.Text = "ONLINE";
|
|
169
|
+
el.detailStatusLabel.Text = "HTTP: OK MCP: OK";
|
|
170
|
+
el.detailStatusLabel.TextColor3 = Color3.fromRGB(34, 197, 94);
|
|
171
|
+
el.step2Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
|
|
172
|
+
el.step2Label.Text = "MCP bridge (OK)";
|
|
173
|
+
el.step3Dot.BackgroundColor3 = Color3.fromRGB(34, 197, 94);
|
|
174
|
+
el.step3Label.Text = "Commands (OK)";
|
|
175
|
+
conn.mcpWaitStartTime = undefined;
|
|
176
|
+
el.troubleshootLabel.Visible = false;
|
|
177
|
+
UI.stopPulseAnimation();
|
|
178
|
+
} else if (!mcpConnected) {
|
|
179
|
+
el.statusLabel.Text = "Waiting for MCP server";
|
|
180
|
+
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
181
|
+
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
182
|
+
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
183
|
+
el.statusText.Text = "WAITING";
|
|
184
|
+
el.detailStatusLabel.Text = "HTTP: OK MCP: ...";
|
|
185
|
+
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
186
|
+
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
187
|
+
el.step2Label.Text = "MCP bridge (waiting...)";
|
|
188
|
+
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
189
|
+
el.step3Label.Text = "Commands (waiting...)";
|
|
190
|
+
if (conn.mcpWaitStartTime === undefined) {
|
|
191
|
+
conn.mcpWaitStartTime = tick();
|
|
192
|
+
}
|
|
193
|
+
const elapsed = tick() - (conn.mcpWaitStartTime ?? tick());
|
|
194
|
+
el.troubleshootLabel.Visible = elapsed > 8;
|
|
195
|
+
UI.startPulseAnimation();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (data.request && mcpConnected) {
|
|
200
|
+
task.spawn(() => {
|
|
201
|
+
const [ok, response] = pcall(() => processRequest(data.request!));
|
|
202
|
+
if (ok) {
|
|
203
|
+
sendResponse(conn, data.requestId!, response);
|
|
204
|
+
} else {
|
|
205
|
+
sendResponse(conn, data.requestId!, { error: tostring(response) });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
} else if (conn.isActive) {
|
|
210
|
+
conn.consecutiveFailures++;
|
|
211
|
+
|
|
212
|
+
if (conn.consecutiveFailures > 1) {
|
|
213
|
+
conn.currentRetryDelay = math.min(
|
|
214
|
+
conn.currentRetryDelay * conn.retryBackoffMultiplier,
|
|
215
|
+
conn.maxRetryDelay,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if (connIndex === State.getActiveTabIndex()) {
|
|
221
|
+
const el = ui;
|
|
222
|
+
if (conn.consecutiveFailures >= conn.maxFailuresBeforeError) {
|
|
223
|
+
el.statusLabel.Text = "Server unavailable";
|
|
224
|
+
el.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
225
|
+
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
|
|
226
|
+
el.statusPulse.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
|
|
227
|
+
el.statusText.Text = "ERROR";
|
|
228
|
+
el.detailStatusLabel.Text = "HTTP: X MCP: X";
|
|
229
|
+
el.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
230
|
+
el.step1Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
|
|
231
|
+
el.step1Label.Text = "HTTP server (error)";
|
|
232
|
+
el.step2Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
|
|
233
|
+
el.step2Label.Text = "MCP bridge (error)";
|
|
234
|
+
el.step3Dot.BackgroundColor3 = Color3.fromRGB(239, 68, 68);
|
|
235
|
+
el.step3Label.Text = "Commands (error)";
|
|
236
|
+
conn.mcpWaitStartTime = undefined;
|
|
237
|
+
el.troubleshootLabel.Visible = false;
|
|
238
|
+
UI.stopPulseAnimation();
|
|
239
|
+
} else if (conn.consecutiveFailures > 5) {
|
|
240
|
+
const waitTime = math.ceil(conn.currentRetryDelay);
|
|
241
|
+
el.statusLabel.Text = `Retrying (${waitTime}s)`;
|
|
242
|
+
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
243
|
+
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
244
|
+
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
245
|
+
el.statusText.Text = "RETRY";
|
|
246
|
+
el.detailStatusLabel.Text = "HTTP: ... MCP: ...";
|
|
247
|
+
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
248
|
+
el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
249
|
+
el.step1Label.Text = "HTTP server (retrying...)";
|
|
250
|
+
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
251
|
+
el.step2Label.Text = "MCP bridge (retrying...)";
|
|
252
|
+
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
253
|
+
el.step3Label.Text = "Commands (retrying...)";
|
|
254
|
+
conn.mcpWaitStartTime = undefined;
|
|
255
|
+
el.troubleshootLabel.Visible = false;
|
|
256
|
+
UI.startPulseAnimation();
|
|
257
|
+
} else if (conn.consecutiveFailures > 1) {
|
|
258
|
+
el.statusLabel.Text = `Connecting (attempt ${conn.consecutiveFailures})`;
|
|
259
|
+
el.statusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
260
|
+
el.statusIndicator.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
261
|
+
el.statusPulse.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
262
|
+
el.statusText.Text = "CONNECTING";
|
|
263
|
+
el.detailStatusLabel.Text = "HTTP: ... MCP: ...";
|
|
264
|
+
el.detailStatusLabel.TextColor3 = Color3.fromRGB(245, 158, 11);
|
|
265
|
+
el.step1Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
266
|
+
el.step1Label.Text = "HTTP server (connecting...)";
|
|
267
|
+
el.step2Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
268
|
+
el.step2Label.Text = "MCP bridge (connecting...)";
|
|
269
|
+
el.step3Dot.BackgroundColor3 = Color3.fromRGB(245, 158, 11);
|
|
270
|
+
el.step3Label.Text = "Commands (connecting...)";
|
|
271
|
+
conn.mcpWaitStartTime = undefined;
|
|
272
|
+
el.troubleshootLabel.Visible = false;
|
|
273
|
+
UI.startPulseAnimation();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
function activatePlugin(connIndex?: number) {
|
|
281
|
+
const idx = connIndex ?? State.getActiveTabIndex();
|
|
282
|
+
const conn = State.getConnection(idx);
|
|
283
|
+
if (!conn) return;
|
|
284
|
+
|
|
285
|
+
const ui = UI.getElements();
|
|
286
|
+
|
|
287
|
+
conn.isActive = true;
|
|
288
|
+
conn.consecutiveFailures = 0;
|
|
289
|
+
conn.currentRetryDelay = 0.5;
|
|
290
|
+
ui.screenGui.Enabled = true;
|
|
291
|
+
|
|
292
|
+
if (idx === State.getActiveTabIndex()) {
|
|
293
|
+
conn.serverUrl = ui.urlInput.Text;
|
|
294
|
+
const [portStr] = conn.serverUrl.match(":(%d+)$");
|
|
295
|
+
if (portStr) conn.port = tonumber(portStr) ?? conn.port;
|
|
296
|
+
UI.updateTabLabel(idx);
|
|
297
|
+
UI.updateUIState();
|
|
298
|
+
}
|
|
299
|
+
UI.updateTabDot(idx);
|
|
300
|
+
|
|
301
|
+
task.spawn(() => {
|
|
302
|
+
if (!conn.heartbeatConnection) {
|
|
303
|
+
conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
|
|
304
|
+
const now = tick();
|
|
305
|
+
const currentInterval = conn.consecutiveFailures > 5 ? conn.currentRetryDelay : conn.pollInterval;
|
|
306
|
+
if (now - conn.lastPoll > currentInterval) {
|
|
307
|
+
conn.lastPoll = now;
|
|
308
|
+
pollForRequests(idx);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const [readyOk, readyResult] = pcall(() => {
|
|
314
|
+
return HttpService.RequestAsync({
|
|
315
|
+
Url: `${conn.serverUrl}/ready`,
|
|
316
|
+
Method: "POST",
|
|
317
|
+
Headers: { "Content-Type": "application/json" },
|
|
318
|
+
Body: HttpService.JSONEncode({ instanceId, role: detectRole(), pluginReady: true, timestamp: tick() }),
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
if (readyOk && readyResult.Success) {
|
|
322
|
+
const [parseOk, readyData] = pcall(() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse);
|
|
323
|
+
if (parseOk && readyData.assignedRole) {
|
|
324
|
+
assignedRole = readyData.assignedRole;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function deactivatePlugin(connIndex?: number) {
|
|
331
|
+
const idx = connIndex ?? State.getActiveTabIndex();
|
|
332
|
+
const conn = State.getConnection(idx);
|
|
333
|
+
if (!conn) return;
|
|
334
|
+
|
|
335
|
+
conn.isActive = false;
|
|
336
|
+
conn.lastMcpOk = false;
|
|
337
|
+
|
|
338
|
+
if (idx === State.getActiveTabIndex()) UI.updateUIState();
|
|
339
|
+
UI.updateTabDot(idx);
|
|
340
|
+
|
|
341
|
+
pcall(() => {
|
|
342
|
+
HttpService.RequestAsync({
|
|
343
|
+
Url: `${conn.serverUrl}/disconnect`,
|
|
344
|
+
Method: "POST",
|
|
345
|
+
Headers: { "Content-Type": "application/json" },
|
|
346
|
+
Body: HttpService.JSONEncode({ instanceId, timestamp: tick() }),
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (conn.heartbeatConnection) {
|
|
351
|
+
conn.heartbeatConnection.Disconnect();
|
|
352
|
+
conn.heartbeatConnection = undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
conn.consecutiveFailures = 0;
|
|
356
|
+
conn.currentRetryDelay = 0.5;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function deactivateAll() {
|
|
360
|
+
for (let i = 0; i < State.getConnections().size(); i++) {
|
|
361
|
+
if (State.getConnections()[i].isActive) {
|
|
362
|
+
deactivatePlugin(i);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function checkForUpdates() {
|
|
368
|
+
task.spawn(() => {
|
|
369
|
+
const [success, result] = pcall(() => {
|
|
370
|
+
return HttpService.RequestAsync({
|
|
371
|
+
Url: "https://registry.npmjs.org/@chrrxs/robloxstudio-mcp/latest",
|
|
372
|
+
Method: "GET",
|
|
373
|
+
Headers: { Accept: "application/json" },
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (success && result.Success) {
|
|
378
|
+
const [ok, data] = pcall(() => HttpService.JSONDecode(result.Body) as { version?: string });
|
|
379
|
+
if (ok && data?.version) {
|
|
380
|
+
const latestVersion = data.version;
|
|
381
|
+
if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
|
|
382
|
+
const ui = UI.getElements();
|
|
383
|
+
ui.updateBannerText.Text = `v${latestVersion} available - github.com/chrrxs/robloxstudio-mcp`;
|
|
384
|
+
ui.updateBanner.Visible = true;
|
|
385
|
+
ui.contentFrame.Position = new UDim2(0, 8, 0, 92);
|
|
386
|
+
ui.contentFrame.Size = new UDim2(1, -16, 1, -100);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export = {
|
|
394
|
+
getConnectionStatus,
|
|
395
|
+
activatePlugin,
|
|
396
|
+
deactivatePlugin,
|
|
397
|
+
deactivateAll,
|
|
398
|
+
checkForUpdates,
|
|
399
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const ChangeHistoryService = game.GetService("ChangeHistoryService");
|
|
2
|
+
|
|
3
|
+
type RecordingId = string | undefined;
|
|
4
|
+
|
|
5
|
+
function beginRecording(actionName: string): RecordingId {
|
|
6
|
+
const [success, result] = pcall(() => ChangeHistoryService.TryBeginRecording(`MCP: ${actionName}`));
|
|
7
|
+
if (success) {
|
|
8
|
+
return result as RecordingId;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function finishRecording(recordingId: RecordingId, shouldCommit: boolean) {
|
|
14
|
+
if (recordingId === undefined) return;
|
|
15
|
+
|
|
16
|
+
const operation = shouldCommit
|
|
17
|
+
? Enum.FinishRecordingOperation.Commit
|
|
18
|
+
: Enum.FinishRecordingOperation.Cancel;
|
|
19
|
+
|
|
20
|
+
pcall(() => {
|
|
21
|
+
ChangeHistoryService.FinishRecording(recordingId, operation);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export = {
|
|
26
|
+
beginRecording,
|
|
27
|
+
finishRecording,
|
|
28
|
+
};
|