@chrrxs/robloxstudio-mcp 2.14.0 → 2.15.1
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 +233 -22
- package/package.json +2 -2
- package/studio-plugin/INSTALLATION.md +13 -3
- package/studio-plugin/MCPInspectorPlugin.rbxmx +336 -34
- package/studio-plugin/MCPPlugin.rbxmx +336 -34
- package/studio-plugin/src/modules/ClientBroker.ts +12 -2
- package/studio-plugin/src/modules/Communication.ts +22 -5
- package/studio-plugin/src/modules/State.ts +2 -0
- package/studio-plugin/src/modules/UI.ts +20 -0
- package/studio-plugin/src/modules/handlers/SceneAnalysisHandlers.ts +216 -0
- package/studio-plugin/src/types/index.d.ts +6 -0
|
@@ -16,6 +16,7 @@ import InputHandlers from "./handlers/InputHandlers";
|
|
|
16
16
|
import LogHandlers from "./handlers/LogHandlers";
|
|
17
17
|
import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
18
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
19
|
+
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
19
20
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
20
21
|
|
|
21
22
|
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
@@ -46,6 +47,8 @@ function computeInstanceId(): string {
|
|
|
46
47
|
const instanceId = computeInstanceId();
|
|
47
48
|
let assignedRole: string | undefined;
|
|
48
49
|
let duplicateInstanceRole = false;
|
|
50
|
+
let hasVersionMismatch = false;
|
|
51
|
+
let lastVersionMismatchWarningKey: string | undefined;
|
|
49
52
|
|
|
50
53
|
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
51
54
|
// /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
@@ -162,6 +165,7 @@ const routeMap: Record<string, Handler> = {
|
|
|
162
165
|
"/api/import-rbxm": SerializationHandlers.importRbxm,
|
|
163
166
|
|
|
164
167
|
"/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
|
|
168
|
+
"/api/get-scene-analysis": SceneAnalysisHandlers.getSceneAnalysis,
|
|
165
169
|
};
|
|
166
170
|
|
|
167
171
|
function processRequest(request: RequestPayload): unknown {
|
|
@@ -236,6 +240,8 @@ function sendReady(conn: Connection): void {
|
|
|
236
240
|
placeName: resolvePlaceName(),
|
|
237
241
|
dataModelName: game.Name,
|
|
238
242
|
isRunning: RunService.IsRunning(),
|
|
243
|
+
pluginVersion: State.CURRENT_VERSION,
|
|
244
|
+
pluginVariant: State.PLUGIN_VARIANT,
|
|
239
245
|
pluginReady: true,
|
|
240
246
|
timestamp: tick(),
|
|
241
247
|
}),
|
|
@@ -299,6 +305,19 @@ function pollForRequests(connIndex: number) {
|
|
|
299
305
|
const mcpConnected = data.mcpConnected === true;
|
|
300
306
|
conn.lastHttpOk = true;
|
|
301
307
|
conn.lastMcpOk = mcpConnected;
|
|
308
|
+
const serverVersion = data.serverVersion ?? "unknown";
|
|
309
|
+
if (data.versionMismatch === true) {
|
|
310
|
+
hasVersionMismatch = true;
|
|
311
|
+
const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
|
|
312
|
+
if (lastVersionMismatchWarningKey !== warningKey) {
|
|
313
|
+
lastVersionMismatchWarningKey = warningKey;
|
|
314
|
+
warn(`[MCPPlugin] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
|
|
315
|
+
}
|
|
316
|
+
UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
|
|
317
|
+
} else if (hasVersionMismatch) {
|
|
318
|
+
hasVersionMismatch = false;
|
|
319
|
+
UI.hideBanner("version-mismatch");
|
|
320
|
+
}
|
|
302
321
|
|
|
303
322
|
// Server tells us when its in-memory instances map doesn't have us
|
|
304
323
|
// (e.g. after an MCP process restart). Re-issue /ready immediately so
|
|
@@ -537,11 +556,9 @@ function checkForUpdates() {
|
|
|
537
556
|
if (ok && data?.version) {
|
|
538
557
|
const latestVersion = data.version;
|
|
539
558
|
if (Utils.compareVersions(State.CURRENT_VERSION, latestVersion) < 0) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
ui.contentFrame.Position = new UDim2(0, 8, 0, 92);
|
|
544
|
-
ui.contentFrame.Size = new UDim2(1, -16, 1, -100);
|
|
559
|
+
if (!hasVersionMismatch) {
|
|
560
|
+
UI.showBanner("update", `v${latestVersion} available - github.com/chrrxs/robloxstudio-mcp`);
|
|
561
|
+
}
|
|
545
562
|
}
|
|
546
563
|
}
|
|
547
564
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Connection } from "../types";
|
|
2
2
|
|
|
3
3
|
const CURRENT_VERSION = "__VERSION__";
|
|
4
|
+
const PLUGIN_VARIANT = "__PLUGIN_VARIANT__";
|
|
4
5
|
const MAX_CONNECTIONS = 5;
|
|
5
6
|
const BASE_PORT = 58741;
|
|
6
7
|
let activeTabIndex = 0;
|
|
@@ -81,6 +82,7 @@ function getConnections(): Connection[] {
|
|
|
81
82
|
|
|
82
83
|
export = {
|
|
83
84
|
CURRENT_VERSION,
|
|
85
|
+
PLUGIN_VARIANT,
|
|
84
86
|
MAX_CONNECTIONS,
|
|
85
87
|
BASE_PORT,
|
|
86
88
|
connections,
|
|
@@ -38,6 +38,7 @@ interface ToolbarIcons {
|
|
|
38
38
|
let toolbarButton: PluginToolbarButton | undefined;
|
|
39
39
|
let toolbarIcons: ToolbarIcons | undefined;
|
|
40
40
|
let lastToolbarIcon: string | undefined;
|
|
41
|
+
let activeBannerKind: string | undefined;
|
|
41
42
|
|
|
42
43
|
function setToolbarButton(btn: PluginToolbarButton, icons: ToolbarIcons) {
|
|
43
44
|
toolbarButton = btn;
|
|
@@ -77,6 +78,23 @@ function tweenProp(instance: Instance, props: Record<string, unknown>) {
|
|
|
77
78
|
TweenService.Create(instance, TWEEN_QUICK, props as unknown as { [key: string]: unknown }).Play();
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
function showBanner(kind: string, text: string) {
|
|
82
|
+
activeBannerKind = kind;
|
|
83
|
+
elements.updateBannerText.Text = text;
|
|
84
|
+
elements.updateBanner.Visible = true;
|
|
85
|
+
elements.contentFrame.Position = new UDim2(0, 8, 0, 92);
|
|
86
|
+
elements.contentFrame.Size = new UDim2(1, -16, 1, -100);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hideBanner(kind?: string) {
|
|
90
|
+
if (kind !== undefined && activeBannerKind !== kind) return;
|
|
91
|
+
activeBannerKind = undefined;
|
|
92
|
+
elements.updateBanner.Visible = false;
|
|
93
|
+
elements.updateBannerText.Text = "";
|
|
94
|
+
elements.contentFrame.Position = new UDim2(0, 8, 0, 66);
|
|
95
|
+
elements.contentFrame.Size = new UDim2(1, -16, 1, -74);
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
const C = {
|
|
81
99
|
bg: Color3.fromRGB(14, 14, 14),
|
|
82
100
|
card: Color3.fromRGB(22, 22, 22),
|
|
@@ -759,5 +777,7 @@ export = {
|
|
|
759
777
|
startPulseAnimation,
|
|
760
778
|
setToolbarButton,
|
|
761
779
|
updateToolbarIcon,
|
|
780
|
+
showBanner,
|
|
781
|
+
hideBanner,
|
|
762
782
|
getElements: () => elements,
|
|
763
783
|
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
interface SceneAnalysisServiceLike extends Instance {
|
|
2
|
+
GetInstanceCompositionAsync(this: SceneAnalysisServiceLike): unknown;
|
|
3
|
+
GetScriptMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
4
|
+
GetUnparentedInstancesAsync(this: SceneAnalysisServiceLike): unknown;
|
|
5
|
+
GetTriangleCompositionAsync(this: SceneAnalysisServiceLike): unknown;
|
|
6
|
+
GetAnimationMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
7
|
+
GetAudioMemoryAsync(this: SceneAnalysisServiceLike): unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SceneAnalysisNode {
|
|
11
|
+
Name?: string;
|
|
12
|
+
Size?: number;
|
|
13
|
+
Sizes?: Record<string, number>;
|
|
14
|
+
Children?: SceneAnalysisNode[];
|
|
15
|
+
AssetId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ModeConfig {
|
|
19
|
+
method: string;
|
|
20
|
+
query: (service: SceneAnalysisServiceLike) => unknown;
|
|
21
|
+
sortByTriangles?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MODE_CONFIGS: Record<string, ModeConfig> = {
|
|
25
|
+
instance_composition: {
|
|
26
|
+
method: "GetInstanceCompositionAsync",
|
|
27
|
+
query: (service) => service.GetInstanceCompositionAsync(),
|
|
28
|
+
},
|
|
29
|
+
script_memory: {
|
|
30
|
+
method: "GetScriptMemoryAsync",
|
|
31
|
+
query: (service) => service.GetScriptMemoryAsync(),
|
|
32
|
+
},
|
|
33
|
+
unparented_instances: {
|
|
34
|
+
method: "GetUnparentedInstancesAsync",
|
|
35
|
+
query: (service) => service.GetUnparentedInstancesAsync(),
|
|
36
|
+
},
|
|
37
|
+
triangle_composition: {
|
|
38
|
+
method: "GetTriangleCompositionAsync",
|
|
39
|
+
query: (service) => service.GetTriangleCompositionAsync(),
|
|
40
|
+
sortByTriangles: true,
|
|
41
|
+
},
|
|
42
|
+
animation_memory: {
|
|
43
|
+
method: "GetAnimationMemoryAsync",
|
|
44
|
+
query: (service) => service.GetAnimationMemoryAsync(),
|
|
45
|
+
},
|
|
46
|
+
audio_memory: {
|
|
47
|
+
method: "GetAudioMemoryAsync",
|
|
48
|
+
query: (service) => service.GetAudioMemoryAsync(),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ALL_MODES = [
|
|
53
|
+
"instance_composition",
|
|
54
|
+
"script_memory",
|
|
55
|
+
"unparented_instances",
|
|
56
|
+
"triangle_composition",
|
|
57
|
+
"animation_memory",
|
|
58
|
+
"audio_memory",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function betaDisabledError(): Record<string, unknown> {
|
|
62
|
+
return {
|
|
63
|
+
error: "scene_analysis_not_enabled",
|
|
64
|
+
message: "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
|
|
65
|
+
betaFeatureRequired: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isBetaDisabledError(value: unknown): boolean {
|
|
70
|
+
return typeIs(value, "string") && string.find(value, "SceneAnalysisService is not enabled", 1, true)[0] !== undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getSceneAnalysisService(): SceneAnalysisServiceLike | Record<string, unknown> {
|
|
74
|
+
const provider = game as unknown as { GetService(serviceName: string): Instance };
|
|
75
|
+
const [ok, service] = pcall(() => provider.GetService("SceneAnalysisService") as SceneAnalysisServiceLike);
|
|
76
|
+
if (!ok || !service) {
|
|
77
|
+
return {
|
|
78
|
+
error: "scene_analysis_unavailable",
|
|
79
|
+
message: `SceneAnalysisService is unavailable: ${tostring(service)}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return service;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeMode(mode: unknown): string | Record<string, unknown> {
|
|
86
|
+
if (mode === undefined || mode === "all") return "all";
|
|
87
|
+
if (!typeIs(mode, "string") || MODE_CONFIGS[mode] === undefined) {
|
|
88
|
+
return {
|
|
89
|
+
error: "invalid_mode",
|
|
90
|
+
message: `mode must be one of: all, ${ALL_MODES.join(", ")}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return mode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeTopN(topN: unknown): number {
|
|
97
|
+
if (!typeIs(topN, "number")) return 10;
|
|
98
|
+
return math.clamp(math.floor(topN), 1, 100);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function countLeaves(node: SceneAnalysisNode): number {
|
|
102
|
+
const children = node.Children;
|
|
103
|
+
if (children && children.size() > 0) {
|
|
104
|
+
let total = 0;
|
|
105
|
+
for (const child of children) total += countLeaves(child);
|
|
106
|
+
return total;
|
|
107
|
+
}
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function flattenLeaves(node: SceneAnalysisNode, out: SceneAnalysisNode[]): void {
|
|
112
|
+
const children = node.Children;
|
|
113
|
+
if (children && children.size() > 0) {
|
|
114
|
+
for (const child of children) flattenLeaves(child, out);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
out.push(node);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function compactEntry(node: SceneAnalysisNode): Record<string, unknown> {
|
|
121
|
+
const entry: Record<string, unknown> = {
|
|
122
|
+
name: node.Name,
|
|
123
|
+
};
|
|
124
|
+
if (node.Size !== undefined) entry.size = node.Size;
|
|
125
|
+
if (node.Sizes !== undefined) entry.sizes = node.Sizes;
|
|
126
|
+
if (node.AssetId !== undefined) entry.asset_id = node.AssetId;
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compactRoot(node: SceneAnalysisNode, leafCount: number): Record<string, unknown> {
|
|
131
|
+
const children = node.Children;
|
|
132
|
+
const root: Record<string, unknown> = {
|
|
133
|
+
name: node.Name,
|
|
134
|
+
child_count: children ? children.size() : 0,
|
|
135
|
+
leaf_count: leafCount,
|
|
136
|
+
};
|
|
137
|
+
if (node.Size !== undefined) root.size = node.Size;
|
|
138
|
+
if (node.Sizes !== undefined) root.sizes = node.Sizes;
|
|
139
|
+
return root;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function metric(node: SceneAnalysisNode, sortByTriangles: boolean): number {
|
|
143
|
+
if (sortByTriangles) {
|
|
144
|
+
const sizes = node.Sizes;
|
|
145
|
+
const triangles = sizes ? sizes.Triangles : undefined;
|
|
146
|
+
return triangles ?? 0;
|
|
147
|
+
}
|
|
148
|
+
return node.Size ?? 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function summarizeMode(
|
|
152
|
+
mode: string,
|
|
153
|
+
config: ModeConfig,
|
|
154
|
+
service: SceneAnalysisServiceLike,
|
|
155
|
+
topN: number,
|
|
156
|
+
raw: boolean,
|
|
157
|
+
): Record<string, unknown> {
|
|
158
|
+
const started = os.clock();
|
|
159
|
+
const [ok, result] = pcall(() => config.query(service) as SceneAnalysisNode);
|
|
160
|
+
const elapsedMs = math.floor((os.clock() - started) * 1000);
|
|
161
|
+
|
|
162
|
+
if (!ok) {
|
|
163
|
+
if (isBetaDisabledError(result)) return betaDisabledError();
|
|
164
|
+
return {
|
|
165
|
+
error: "scene_analysis_query_failed",
|
|
166
|
+
mode,
|
|
167
|
+
method: config.method,
|
|
168
|
+
message: tostring(result),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const tree = result as SceneAnalysisNode;
|
|
173
|
+
const leaves: SceneAnalysisNode[] = [];
|
|
174
|
+
flattenLeaves(tree, leaves);
|
|
175
|
+
leaves.sort((a, b) => metric(a, config.sortByTriangles === true) > metric(b, config.sortByTriangles === true));
|
|
176
|
+
|
|
177
|
+
const top: Record<string, unknown>[] = [];
|
|
178
|
+
for (let i = 0; i < math.min(topN, leaves.size()); i++) {
|
|
179
|
+
top.push(compactEntry(leaves[i]));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const body: Record<string, unknown> = {
|
|
183
|
+
mode,
|
|
184
|
+
method: config.method,
|
|
185
|
+
elapsed_ms: elapsedMs,
|
|
186
|
+
root: compactRoot(tree, leaves.size()),
|
|
187
|
+
top,
|
|
188
|
+
};
|
|
189
|
+
if (raw) body.tree = tree;
|
|
190
|
+
return body;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getSceneAnalysis(requestData: Record<string, unknown>): unknown {
|
|
194
|
+
const mode = normalizeMode(requestData.mode);
|
|
195
|
+
if (!typeIs(mode, "string")) return mode;
|
|
196
|
+
|
|
197
|
+
const serviceOrError = getSceneAnalysisService();
|
|
198
|
+
if (!serviceOrError.IsA) return serviceOrError;
|
|
199
|
+
const service = serviceOrError as SceneAnalysisServiceLike;
|
|
200
|
+
const topN = normalizeTopN(requestData.topN);
|
|
201
|
+
const raw = requestData.raw === true;
|
|
202
|
+
|
|
203
|
+
if (mode !== "all") {
|
|
204
|
+
return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const body: Record<string, unknown> = {};
|
|
208
|
+
for (const m of ALL_MODES) {
|
|
209
|
+
const result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw);
|
|
210
|
+
if (result.error === "scene_analysis_not_enabled") return result;
|
|
211
|
+
body[m] = result;
|
|
212
|
+
}
|
|
213
|
+
return body;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export = { getSceneAnalysis };
|
|
@@ -30,6 +30,10 @@ export interface RequestPayload {
|
|
|
30
30
|
|
|
31
31
|
export interface PollResponse {
|
|
32
32
|
mcpConnected: boolean;
|
|
33
|
+
serverVersion?: string;
|
|
34
|
+
pluginVersion?: string;
|
|
35
|
+
pluginVariant?: string;
|
|
36
|
+
versionMismatch?: boolean;
|
|
33
37
|
request?: RequestPayload;
|
|
34
38
|
requestId?: string;
|
|
35
39
|
// Server signals knownInstance=false when its in-memory instances map
|
|
@@ -42,6 +46,8 @@ export interface ReadyResponse {
|
|
|
42
46
|
success: boolean;
|
|
43
47
|
assignedRole?: string;
|
|
44
48
|
instanceId?: string;
|
|
49
|
+
serverVersion?: string;
|
|
50
|
+
versionMismatch?: boolean;
|
|
45
51
|
error?: string;
|
|
46
52
|
message?: string;
|
|
47
53
|
}
|