@chrrxs/robloxstudio-mcp 2.10.1 → 2.11.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 +225 -9
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +281 -17
- package/studio-plugin/MCPPlugin.rbxmx +281 -17
- package/studio-plugin/src/modules/ClientBroker.ts +5 -0
- package/studio-plugin/src/modules/Communication.ts +7 -0
- package/studio-plugin/src/modules/UI.ts +1 -1
- package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +44 -0
- package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +172 -0
package/dist/index.js
CHANGED
|
@@ -372,6 +372,9 @@ var init_http_server = __esm({
|
|
|
372
372
|
simulate_mouse_input: (tools, body) => tools.simulateMouseInput(body.action, body.x, body.y, body.button, body.scrollDirection, body.target),
|
|
373
373
|
simulate_keyboard_input: (tools, body) => tools.simulateKeyboardInput(body.keyCode, body.action, body.duration, body.target),
|
|
374
374
|
character_navigation: (tools, body) => tools.characterNavigation(body.position, body.instancePath, body.waitForCompletion, body.timeout, body.target),
|
|
375
|
+
get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags),
|
|
376
|
+
export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target),
|
|
377
|
+
import_rbxm: (tools, body) => tools.importRbxm(body.source, body.parent_path, body.target),
|
|
375
378
|
find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
|
|
376
379
|
caseSensitive: body.caseSensitive,
|
|
377
380
|
usePattern: body.usePattern,
|
|
@@ -2777,6 +2780,139 @@ ${code}`
|
|
|
2777
2780
|
}]
|
|
2778
2781
|
};
|
|
2779
2782
|
}
|
|
2783
|
+
async getMemoryBreakdown(target, tags) {
|
|
2784
|
+
const tgt = target ?? "all";
|
|
2785
|
+
const data = {};
|
|
2786
|
+
if (tags !== void 0)
|
|
2787
|
+
data.tags = tags;
|
|
2788
|
+
if (tgt !== "all") {
|
|
2789
|
+
const response = await this.client.request("/api/get-memory-breakdown", data, tgt);
|
|
2790
|
+
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
2791
|
+
}
|
|
2792
|
+
const targets = this.bridge.getInstances().filter((i) => i.role !== "edit-proxy").map((i) => i.role);
|
|
2793
|
+
const responses = await Promise.allSettled(targets.map(async (t) => ({
|
|
2794
|
+
peer: t,
|
|
2795
|
+
result: await this.client.request("/api/get-memory-breakdown", data, t)
|
|
2796
|
+
})));
|
|
2797
|
+
const body = {};
|
|
2798
|
+
for (let i = 0; i < responses.length; i++) {
|
|
2799
|
+
const r = responses[i];
|
|
2800
|
+
const peer = targets[i];
|
|
2801
|
+
if (r.status === "fulfilled") {
|
|
2802
|
+
body[peer] = r.value.result;
|
|
2803
|
+
} else {
|
|
2804
|
+
body[peer] = { error: "disconnected" };
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
2808
|
+
}
|
|
2809
|
+
async exportRbxm(instancePaths, outputPath, target) {
|
|
2810
|
+
if (!Array.isArray(instancePaths) || instancePaths.length === 0) {
|
|
2811
|
+
throw new Error("instance_paths must be a non-empty array for export_rbxm");
|
|
2812
|
+
}
|
|
2813
|
+
if (!outputPath || typeof outputPath !== "string") {
|
|
2814
|
+
throw new Error("output_path is required for export_rbxm");
|
|
2815
|
+
}
|
|
2816
|
+
const tgt = target || "edit";
|
|
2817
|
+
if (tgt !== "edit" && tgt !== "server") {
|
|
2818
|
+
throw new Error(`export_rbxm target must be "edit" or "server" (got: ${tgt})`);
|
|
2819
|
+
}
|
|
2820
|
+
const response = await this.client.request("/api/export-rbxm", { instance_paths: instancePaths }, tgt);
|
|
2821
|
+
if (response.error) {
|
|
2822
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: response.error }) }] };
|
|
2823
|
+
}
|
|
2824
|
+
if (!response.base64) {
|
|
2825
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
|
|
2826
|
+
}
|
|
2827
|
+
const bytes = Buffer.from(response.base64, "base64");
|
|
2828
|
+
const resolved = path.resolve(outputPath);
|
|
2829
|
+
try {
|
|
2830
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
2831
|
+
fs.writeFileSync(resolved, bytes);
|
|
2832
|
+
} catch (err) {
|
|
2833
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err.message}` }) }] };
|
|
2834
|
+
}
|
|
2835
|
+
return {
|
|
2836
|
+
content: [{
|
|
2837
|
+
type: "text",
|
|
2838
|
+
text: JSON.stringify({
|
|
2839
|
+
bytes_written: bytes.length,
|
|
2840
|
+
instance_count: response.instance_count ?? instancePaths.length,
|
|
2841
|
+
output_path: resolved
|
|
2842
|
+
})
|
|
2843
|
+
}]
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
async importRbxm(source, parentPath, target) {
|
|
2847
|
+
if (!source || typeof source !== "object") {
|
|
2848
|
+
throw new Error("source is required for import_rbxm");
|
|
2849
|
+
}
|
|
2850
|
+
if (!parentPath || typeof parentPath !== "string") {
|
|
2851
|
+
throw new Error("parent_path is required for import_rbxm");
|
|
2852
|
+
}
|
|
2853
|
+
const tgt = target || "edit";
|
|
2854
|
+
if (tgt !== "edit" && tgt !== "server") {
|
|
2855
|
+
throw new Error(`import_rbxm target must be "edit" or "server" (got: ${tgt})`);
|
|
2856
|
+
}
|
|
2857
|
+
const modes = ["path", "url", "base64"].filter((k) => source[k] !== void 0);
|
|
2858
|
+
if (modes.length !== 1) {
|
|
2859
|
+
throw new Error(`source must contain exactly one of { path, url, base64 } (got: ${modes.join(", ") || "none"})`);
|
|
2860
|
+
}
|
|
2861
|
+
let bytes;
|
|
2862
|
+
let sourceLabel;
|
|
2863
|
+
if (source.path !== void 0) {
|
|
2864
|
+
const resolved = path.resolve(source.path);
|
|
2865
|
+
try {
|
|
2866
|
+
bytes = fs.readFileSync(resolved);
|
|
2867
|
+
} catch (err) {
|
|
2868
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err.message}` }) }] };
|
|
2869
|
+
}
|
|
2870
|
+
sourceLabel = resolved;
|
|
2871
|
+
} else if (source.url !== void 0) {
|
|
2872
|
+
let parsedUrl;
|
|
2873
|
+
try {
|
|
2874
|
+
parsedUrl = new URL(source.url);
|
|
2875
|
+
} catch {
|
|
2876
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url is not a valid URL: ${source.url}` }) }] };
|
|
2877
|
+
}
|
|
2878
|
+
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
2879
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url must use http(s); got ${parsedUrl.protocol}` }) }] };
|
|
2880
|
+
}
|
|
2881
|
+
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
|
2882
|
+
try {
|
|
2883
|
+
const res = await fetch(source.url);
|
|
2884
|
+
if (!res.ok) {
|
|
2885
|
+
const snippet = (await res.text()).slice(0, 500);
|
|
2886
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} returned ${res.status}: ${snippet}` }) }] };
|
|
2887
|
+
}
|
|
2888
|
+
const claimed = Number(res.headers.get("content-length") ?? "0");
|
|
2889
|
+
if (claimed > MAX_IMPORT_BYTES) {
|
|
2890
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: content-length ${claimed} exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
|
|
2891
|
+
}
|
|
2892
|
+
const arr = await res.arrayBuffer();
|
|
2893
|
+
if (arr.byteLength > MAX_IMPORT_BYTES) {
|
|
2894
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: downloaded ${arr.byteLength} bytes exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
|
|
2895
|
+
}
|
|
2896
|
+
bytes = Buffer.from(arr);
|
|
2897
|
+
} catch (err) {
|
|
2898
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} failed: ${err.message}` }) }] };
|
|
2899
|
+
}
|
|
2900
|
+
sourceLabel = source.url;
|
|
2901
|
+
} else {
|
|
2902
|
+
try {
|
|
2903
|
+
bytes = Buffer.from(source.base64, "base64");
|
|
2904
|
+
} catch (err) {
|
|
2905
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `base64 decode failed: ${err.message}` }) }] };
|
|
2906
|
+
}
|
|
2907
|
+
sourceLabel = `base64(${bytes.length}B)`;
|
|
2908
|
+
}
|
|
2909
|
+
const response = await this.client.request("/api/import-rbxm", {
|
|
2910
|
+
base64: bytes.toString("base64"),
|
|
2911
|
+
parent_path: parentPath,
|
|
2912
|
+
source_label: sourceLabel
|
|
2913
|
+
}, tgt);
|
|
2914
|
+
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
2915
|
+
}
|
|
2780
2916
|
async captureScreenshot() {
|
|
2781
2917
|
const response = await this.client.request("/api/capture-screenshot", {});
|
|
2782
2918
|
if (response.error) {
|
|
@@ -3067,7 +3203,7 @@ var init_server = __esm({
|
|
|
3067
3203
|
let promotionInterval;
|
|
3068
3204
|
try {
|
|
3069
3205
|
primaryApp = createHttpServer(this.tools, this.bridge, this.allowedToolNames, this.config);
|
|
3070
|
-
const result = await listenWithRetry(primaryApp, host, basePort,
|
|
3206
|
+
const result = await listenWithRetry(primaryApp, host, basePort, 1);
|
|
3071
3207
|
httpHandle = result.server;
|
|
3072
3208
|
boundPort = result.port;
|
|
3073
3209
|
console.error(`HTTP server listening on ${host}:${boundPort} for Studio plugin (primary mode)`);
|
|
@@ -3078,25 +3214,25 @@ var init_server = __esm({
|
|
|
3078
3214
|
const proxyBridge = new ProxyBridgeService(`http://localhost:${basePort}`);
|
|
3079
3215
|
this.bridge = proxyBridge;
|
|
3080
3216
|
this.tools = new RobloxStudioTools(this.bridge);
|
|
3081
|
-
console.error(`
|
|
3217
|
+
console.error(`Port ${basePort} in use - entering proxy mode (forwarding to localhost:${basePort})`);
|
|
3082
3218
|
const promotionIntervalMs = parseInt(process.env.ROBLOX_STUDIO_PROXY_PROMOTION_INTERVAL_MS || "5000");
|
|
3083
3219
|
promotionInterval = setInterval(async () => {
|
|
3220
|
+
const candidateBridge = new BridgeService();
|
|
3221
|
+
const candidateTools = new RobloxStudioTools(candidateBridge);
|
|
3222
|
+
const candidateApp = createHttpServer(candidateTools, candidateBridge, this.allowedToolNames, this.config);
|
|
3084
3223
|
try {
|
|
3085
|
-
|
|
3086
|
-
this.
|
|
3087
|
-
|
|
3088
|
-
const result = await listenWithRetry(primaryApp, host, basePort, 5);
|
|
3224
|
+
const result = await listenWithRetry(candidateApp, host, basePort, 1);
|
|
3225
|
+
this.bridge = candidateBridge;
|
|
3226
|
+
this.tools = candidateTools;
|
|
3089
3227
|
httpHandle = result.server;
|
|
3090
3228
|
boundPort = result.port;
|
|
3229
|
+
primaryApp = candidateApp;
|
|
3091
3230
|
bridgeMode = "primary";
|
|
3092
3231
|
primaryApp.setMCPServerActive(true);
|
|
3093
3232
|
console.error(`Promoted from proxy to primary on port ${boundPort}`);
|
|
3094
3233
|
if (promotionInterval)
|
|
3095
3234
|
clearInterval(promotionInterval);
|
|
3096
3235
|
} catch {
|
|
3097
|
-
this.bridge = new ProxyBridgeService(`http://localhost:${basePort}`);
|
|
3098
|
-
this.tools = new RobloxStudioTools(this.bridge);
|
|
3099
|
-
primaryApp = void 0;
|
|
3100
3236
|
}
|
|
3101
3237
|
}, promotionIntervalMs);
|
|
3102
3238
|
}
|
|
@@ -4746,6 +4882,86 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
4746
4882
|
required: ["instancePath", "attributes"]
|
|
4747
4883
|
}
|
|
4748
4884
|
},
|
|
4885
|
+
// === Per-peer memory breakdown ===
|
|
4886
|
+
{
|
|
4887
|
+
name: "get_memory_breakdown",
|
|
4888
|
+
category: "read",
|
|
4889
|
+
description: `Read per-category memory usage by iterating Enum.DeveloperMemoryTag and calling Stats:GetMemoryUsageMbForTag per item (workaround for Stats:GetMemoryUsageMbAllCategories being gated by Capabilities: InternalTest and not callable from plugin context), plus Stats:GetTotalMemoryUsageMb for the rollup. target="all" (default) returns { peer: { total_mb, categories, timestamp } } for every connected peer except edit-proxy; single-peer targets return that peer's object directly. Optional tags whitelist filters to only those DeveloperMemoryTag entries; unknown tags come back with value 0 and are listed in unknown_tags so cross-version drift doesn't error. timestamp is Unix milliseconds (DateTime.now().UnixTimestampMillis). Per-peer MemoryTrackingEnabled=false surfaces as { error } on that peer only.`,
|
|
4890
|
+
inputSchema: {
|
|
4891
|
+
type: "object",
|
|
4892
|
+
properties: {
|
|
4893
|
+
target: {
|
|
4894
|
+
type: "string",
|
|
4895
|
+
description: 'Peer to read from: "edit", "server", "client-N", or "all" (default).'
|
|
4896
|
+
},
|
|
4897
|
+
tags: {
|
|
4898
|
+
type: "array",
|
|
4899
|
+
items: { type: "string" },
|
|
4900
|
+
description: "Optional DeveloperMemoryTag whitelist. Unknown tag names return 0 + unknown_tags list."
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4903
|
+
}
|
|
4904
|
+
},
|
|
4905
|
+
// === SerializationService round-trip ===
|
|
4906
|
+
{
|
|
4907
|
+
name: "export_rbxm",
|
|
4908
|
+
category: "read",
|
|
4909
|
+
description: "Serialize one or more instances to a .rbxm file on disk via SerializationService:SerializeInstancesAsync (engine v668+, PluginSecurity). Throws if any path resolves to nil, a service, or a non-creatable instance.",
|
|
4910
|
+
inputSchema: {
|
|
4911
|
+
type: "object",
|
|
4912
|
+
properties: {
|
|
4913
|
+
instance_paths: {
|
|
4914
|
+
type: "array",
|
|
4915
|
+
items: { type: "string" },
|
|
4916
|
+
description: 'DataModel paths to serialize (e.g. ["Workspace.TestRig", "ServerStorage.Templates.NPC"])'
|
|
4917
|
+
},
|
|
4918
|
+
output_path: {
|
|
4919
|
+
type: "string",
|
|
4920
|
+
description: "Absolute filesystem path where the .rbxm should be written"
|
|
4921
|
+
},
|
|
4922
|
+
target: {
|
|
4923
|
+
type: "string",
|
|
4924
|
+
enum: ["edit", "server"],
|
|
4925
|
+
description: 'Which DataModel to read from (default: "edit"). "server" serializes live runtime state during a playtest.'
|
|
4926
|
+
}
|
|
4927
|
+
},
|
|
4928
|
+
required: ["instance_paths", "output_path"]
|
|
4929
|
+
}
|
|
4930
|
+
},
|
|
4931
|
+
{
|
|
4932
|
+
name: "import_rbxm",
|
|
4933
|
+
category: "write",
|
|
4934
|
+
description: "Deserialize a .rbxm via SerializationService:DeserializeInstancesAsync (engine v668+, PluginSecurity) and parent the resulting instances under parent_path. All-or-nothing parenting: if any single instance fails to parent, every already-parented sibling is unparented and the call errors. Wrapped in ChangeHistoryService for edit target so one Ctrl+Z reverses the whole import.",
|
|
4935
|
+
inputSchema: {
|
|
4936
|
+
type: "object",
|
|
4937
|
+
properties: {
|
|
4938
|
+
source: {
|
|
4939
|
+
type: "object",
|
|
4940
|
+
description: "Exactly one of { path }, { url }, or { base64 }. path = read from local disk; url = http(s) only, fetched by the MCP server process, capped at 50 MiB; base64 = raw bytes inline.",
|
|
4941
|
+
properties: {
|
|
4942
|
+
path: { type: "string" },
|
|
4943
|
+
url: { type: "string" },
|
|
4944
|
+
base64: { type: "string" }
|
|
4945
|
+
},
|
|
4946
|
+
oneOf: [
|
|
4947
|
+
{ required: ["path"] },
|
|
4948
|
+
{ required: ["url"] },
|
|
4949
|
+
{ required: ["base64"] }
|
|
4950
|
+
]
|
|
4951
|
+
},
|
|
4952
|
+
parent_path: {
|
|
4953
|
+
type: "string",
|
|
4954
|
+
description: 'DataModel path of the Instance to parent imported instances under (e.g. "ServerStorage.Imported")'
|
|
4955
|
+
},
|
|
4956
|
+
target: {
|
|
4957
|
+
type: "string",
|
|
4958
|
+
enum: ["edit", "server"],
|
|
4959
|
+
description: 'Which DataModel to import into (default: "edit"). "server" parents into the live play-server DM.'
|
|
4960
|
+
}
|
|
4961
|
+
},
|
|
4962
|
+
required: ["source", "parent_path"]
|
|
4963
|
+
}
|
|
4964
|
+
},
|
|
4749
4965
|
// === Find and Replace ===
|
|
4750
4966
|
{
|
|
4751
4967
|
name: "find_and_replace_in_scripts",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrrxs/robloxstudio-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "MCP Server for Roblox Studio Integration (fork of boshyxd/robloxstudio-mcp with per-peer execute_luau + stop_playtest fixes)",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|