@chrrxs/robloxstudio-mcp 2.10.0 → 2.11.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 +261 -45
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +355 -81
- package/studio-plugin/MCPPlugin.rbxmx +355 -81
- package/studio-plugin/src/modules/ClientBroker.ts +5 -0
- package/studio-plugin/src/modules/Communication.ts +8 -1
- package/studio-plugin/src/modules/EvalBridges.ts +16 -53
- package/studio-plugin/src/modules/UI.ts +36 -0
- package/studio-plugin/src/modules/handlers/MemoryHandlers.ts +44 -0
- package/studio-plugin/src/modules/handlers/QueryHandlers.ts +16 -1
- package/studio-plugin/src/modules/handlers/SerializationHandlers.ts +172 -0
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +1 -13
- package/studio-plugin/src/server/index.server.ts +6 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HttpService, Players, ReplicatedStorage, RunService } from "@rbxts/services";
|
|
2
2
|
import RuntimeLogBuffer from "./RuntimeLogBuffer";
|
|
3
|
+
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
3
4
|
|
|
4
5
|
// The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
5
6
|
// HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
@@ -45,6 +46,7 @@ interface ExecuteResult {
|
|
|
45
46
|
const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
|
|
46
47
|
"/api/execute-luau",
|
|
47
48
|
"/api/get-runtime-logs",
|
|
49
|
+
"/api/get-memory-breakdown",
|
|
48
50
|
]);
|
|
49
51
|
|
|
50
52
|
interface ReadyResponseBody {
|
|
@@ -145,6 +147,9 @@ function setupClientBroker() {
|
|
|
145
147
|
if (payload && payload.endpoint === "/api/get-runtime-logs") {
|
|
146
148
|
return handleGetRuntimeLogs(payload.data);
|
|
147
149
|
}
|
|
150
|
+
if (payload && payload.endpoint === "/api/get-memory-breakdown") {
|
|
151
|
+
return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
|
|
152
|
+
}
|
|
148
153
|
if (payload && payload.endpoint === "/api/execute-luau") {
|
|
149
154
|
return handleExecuteLuau(payload.data);
|
|
150
155
|
}
|
|
@@ -13,6 +13,8 @@ import AssetHandlers from "./handlers/AssetHandlers";
|
|
|
13
13
|
import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
14
14
|
import InputHandlers from "./handlers/InputHandlers";
|
|
15
15
|
import LogHandlers from "./handlers/LogHandlers";
|
|
16
|
+
import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
17
|
+
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
16
18
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
17
19
|
|
|
18
20
|
const instanceId = HttpService.GenerateGUID(false);
|
|
@@ -95,6 +97,11 @@ const routeMap: Record<string, Handler> = {
|
|
|
95
97
|
"/api/find-and-replace-in-scripts": ScriptHandlers.findAndReplaceInScripts,
|
|
96
98
|
|
|
97
99
|
"/api/get-runtime-logs": LogHandlers.getRuntimeLogs,
|
|
100
|
+
|
|
101
|
+
"/api/export-rbxm": SerializationHandlers.exportRbxm,
|
|
102
|
+
"/api/import-rbxm": SerializationHandlers.importRbxm,
|
|
103
|
+
|
|
104
|
+
"/api/get-memory-breakdown": MemoryHandlers.getMemoryBreakdown,
|
|
98
105
|
};
|
|
99
106
|
|
|
100
107
|
function processRequest(request: RequestPayload): unknown {
|
|
@@ -181,6 +188,7 @@ function pollForRequests(connIndex: number) {
|
|
|
181
188
|
|
|
182
189
|
const ui = UI.getElements();
|
|
183
190
|
UI.updateTabDot(connIndex);
|
|
191
|
+
if (connIndex === State.getActiveTabIndex()) UI.updateToolbarIcon();
|
|
184
192
|
|
|
185
193
|
if (success && (result.Success || result.StatusCode === 503)) {
|
|
186
194
|
conn.consecutiveFailures = 0;
|
|
@@ -333,7 +341,6 @@ function activatePlugin(connIndex?: number) {
|
|
|
333
341
|
conn.isActive = true;
|
|
334
342
|
conn.consecutiveFailures = 0;
|
|
335
343
|
conn.currentRetryDelay = 0.5;
|
|
336
|
-
ui.screenGui.Enabled = true;
|
|
337
344
|
|
|
338
345
|
if (idx === State.getActiveTabIndex()) {
|
|
339
346
|
conn.serverUrl = ui.urlInput.Text;
|
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
// `require(SomeModule)` returns a fresh copy, not the one the running game
|
|
6
6
|
// scripts hold. So runtime-mutated module state is invisible to probes.
|
|
7
7
|
//
|
|
8
|
-
// These bridges fix that by living inside the user's game scripts
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// (
|
|
8
|
+
// These bridges fix that by living inside the user's game scripts. Both
|
|
9
|
+
// peers use the same symmetric shape:
|
|
10
|
+
// - Server: a Script in ServerScriptService that creates a BindableFunction.
|
|
11
|
+
// Plugin (server peer) invokes it with a fresh ModuleScript payload;
|
|
12
|
+
// require() runs inside the Script VM so it shares the running server's
|
|
13
|
+
// require cache.
|
|
12
14
|
// - Client: a LocalScript in StarterPlayer.StarterPlayerScripts that
|
|
13
15
|
// creates a BindableFunction. Plugin invokes it with a fresh ModuleScript
|
|
14
16
|
// payload; require() runs inside the LocalScript VM so it shares the
|
|
15
17
|
// game's require cache.
|
|
16
18
|
//
|
|
19
|
+
// Why ModuleScript+require on both sides (no loadstring): require'd modules
|
|
20
|
+
// run with the security level they were created at and don't need
|
|
21
|
+
// ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
|
|
22
|
+
// when LoadStringEnabled=false (the default in fresh places).
|
|
23
|
+
//
|
|
17
24
|
// Lifecycle: TestHandlers.startPlaytest inserts both scripts into the EDIT
|
|
18
25
|
// DM right before ExecutePlayModeAsync. ExecutePlayModeAsync clones the
|
|
19
26
|
// DataModel into the play DMs, so the scripts come along and run there.
|
|
@@ -47,7 +54,6 @@ const CLIENT_SCRIPT_NAME = "__MCP_ClientEvalBridge";
|
|
|
47
54
|
export const BRIDGE_NAMES = {
|
|
48
55
|
serverScript: SERVER_SCRIPT_NAME,
|
|
49
56
|
clientScript: CLIENT_SCRIPT_NAME,
|
|
50
|
-
serverRemote: "__MCP_ServerEvalRemote",
|
|
51
57
|
serverLocal: "__MCP_ServerEvalLocal",
|
|
52
58
|
clientLocal: "__MCP_ClientEvalBridge",
|
|
53
59
|
} as const;
|
|
@@ -59,7 +65,6 @@ const SERVER_BRIDGE_SOURCE = `
|
|
|
59
65
|
-- stop_playtest. Provides shared-require-cache eval on the server peer for
|
|
60
66
|
-- the eval_server_runtime MCP tool.
|
|
61
67
|
|
|
62
|
-
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
63
68
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
64
69
|
local RunService = game:GetService("RunService")
|
|
65
70
|
|
|
@@ -67,49 +72,18 @@ if not RunService:IsStudio() then
|
|
|
67
72
|
return
|
|
68
73
|
end
|
|
69
74
|
|
|
70
|
-
local function evalCode(source)
|
|
71
|
-
if type(source) ~= "string" then
|
|
72
|
-
return false, "source must be a string"
|
|
73
|
-
end
|
|
74
|
-
local fn, compileErr = loadstring(source, "MCPServerEval")
|
|
75
|
-
if not fn then
|
|
76
|
-
local errStr = tostring(compileErr or "loadstring returned nil")
|
|
77
|
-
-- Roblox returns nil from loadstring when LoadStringEnabled=false.
|
|
78
|
-
-- Surface a clear, actionable error.
|
|
79
|
-
if string.find(errStr, "not enabled", 1, true)
|
|
80
|
-
or string.find(errStr, "disabled", 1, true)
|
|
81
|
-
or errStr == "loadstring returned nil"
|
|
82
|
-
then
|
|
83
|
-
return false,
|
|
84
|
-
"ServerScriptService.LoadStringEnabled is false. eval_server_runtime requires it. "
|
|
85
|
-
.. "Enable it in Studio (ServerScriptService > Properties > LoadStringEnabled = true) "
|
|
86
|
-
.. "and restart the playtest."
|
|
87
|
-
end
|
|
88
|
-
return false, errStr
|
|
89
|
-
end
|
|
90
|
-
return pcall(fn)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
-- Defensive cleanup of stale instances from a prior session.
|
|
94
|
-
local prevRf = ReplicatedStorage:FindFirstChild("${BRIDGE_NAMES.serverRemote}")
|
|
95
|
-
if prevRf then prevRf:Destroy() end
|
|
96
75
|
local prevBf = ServerScriptService:FindFirstChild("${BRIDGE_NAMES.serverLocal}")
|
|
97
76
|
if prevBf then prevBf:Destroy() end
|
|
98
77
|
|
|
99
|
-
local rf = Instance.new("RemoteFunction")
|
|
100
|
-
rf.Name = "${BRIDGE_NAMES.serverRemote}"
|
|
101
|
-
rf.Archivable = false
|
|
102
|
-
rf.Parent = ReplicatedStorage
|
|
103
|
-
rf.OnServerInvoke = function(_player, source)
|
|
104
|
-
return evalCode(source)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
78
|
local bf = Instance.new("BindableFunction")
|
|
108
79
|
bf.Name = "${BRIDGE_NAMES.serverLocal}"
|
|
109
80
|
bf.Archivable = false
|
|
110
81
|
bf.Parent = ServerScriptService
|
|
111
|
-
bf.OnInvoke = function(
|
|
112
|
-
|
|
82
|
+
bf.OnInvoke = function(payload)
|
|
83
|
+
if typeof(payload) ~= "Instance" or not payload:IsA("ModuleScript") then
|
|
84
|
+
return false, "payload must be a ModuleScript instance"
|
|
85
|
+
end
|
|
86
|
+
return pcall(require, payload)
|
|
113
87
|
end
|
|
114
88
|
`;
|
|
115
89
|
|
|
@@ -202,14 +176,3 @@ export function installBridges(): { installed: boolean; error?: string } {
|
|
|
202
176
|
return { installed: true };
|
|
203
177
|
}
|
|
204
178
|
|
|
205
|
-
// Heuristic check so start_playtest can surface a warning when
|
|
206
|
-
// LoadStringEnabled is false (eval_server_runtime won't work in that mode).
|
|
207
|
-
// We can't import the runtime LoadStringEnabled value cleanly without
|
|
208
|
-
// pulling in the type — read defensively.
|
|
209
|
-
export function loadStringEnabled(): boolean {
|
|
210
|
-
const [ok, value] = pcall(
|
|
211
|
-
() => (ServerScriptService as unknown as { LoadStringEnabled: boolean }).LoadStringEnabled,
|
|
212
|
-
);
|
|
213
|
-
return ok && value === true;
|
|
214
|
-
}
|
|
215
|
-
|
|
@@ -30,6 +30,39 @@ let elements: UIElements = undefined!;
|
|
|
30
30
|
let pulseAnimation: Tween | undefined;
|
|
31
31
|
let buttonHover = false;
|
|
32
32
|
|
|
33
|
+
interface ToolbarIcons {
|
|
34
|
+
disconnected: string;
|
|
35
|
+
connecting: string;
|
|
36
|
+
connected: string;
|
|
37
|
+
}
|
|
38
|
+
let toolbarButton: PluginToolbarButton | undefined;
|
|
39
|
+
let toolbarIcons: ToolbarIcons | undefined;
|
|
40
|
+
let lastToolbarIcon: string | undefined;
|
|
41
|
+
|
|
42
|
+
function setToolbarButton(btn: PluginToolbarButton, icons: ToolbarIcons) {
|
|
43
|
+
toolbarButton = btn;
|
|
44
|
+
toolbarIcons = icons;
|
|
45
|
+
lastToolbarIcon = undefined;
|
|
46
|
+
updateToolbarIcon();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function updateToolbarIcon() {
|
|
50
|
+
if (!toolbarButton || !toolbarIcons) return;
|
|
51
|
+
const conn = State.getActiveConnection();
|
|
52
|
+
let nextIcon: string;
|
|
53
|
+
if (!conn || !conn.isActive) {
|
|
54
|
+
nextIcon = toolbarIcons.disconnected;
|
|
55
|
+
} else if (conn.lastHttpOk && conn.lastMcpOk) {
|
|
56
|
+
nextIcon = toolbarIcons.connected;
|
|
57
|
+
} else {
|
|
58
|
+
nextIcon = toolbarIcons.connecting;
|
|
59
|
+
}
|
|
60
|
+
if (nextIcon !== lastToolbarIcon) {
|
|
61
|
+
(toolbarButton as unknown as { Icon: string }).Icon = nextIcon;
|
|
62
|
+
lastToolbarIcon = nextIcon;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
interface TabButton {
|
|
34
67
|
frame: Frame;
|
|
35
68
|
label: TextLabel;
|
|
@@ -596,6 +629,7 @@ function init(pluginRef: Plugin) {
|
|
|
596
629
|
}
|
|
597
630
|
|
|
598
631
|
function updateUIState() {
|
|
632
|
+
updateToolbarIcon();
|
|
599
633
|
const conn = State.getActiveConnection();
|
|
600
634
|
if (!conn) return;
|
|
601
635
|
const el = elements;
|
|
@@ -723,5 +757,7 @@ export = {
|
|
|
723
757
|
updateTabLabel,
|
|
724
758
|
stopPulseAnimation,
|
|
725
759
|
startPulseAnimation,
|
|
760
|
+
setToolbarButton,
|
|
761
|
+
updateToolbarIcon,
|
|
726
762
|
getElements: () => elements,
|
|
727
763
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const Stats = game.GetService("Stats");
|
|
2
|
+
|
|
3
|
+
// GetMemoryUsageMbAllCategories is gated by capability "InternalTest" and not
|
|
4
|
+
// callable from plugin context. GetMemoryUsageMbForTag is not - so we iterate
|
|
5
|
+
// Enum.DeveloperMemoryTag and ask per-tag.
|
|
6
|
+
function getMemoryBreakdown(requestData: Record<string, unknown>): unknown {
|
|
7
|
+
if (!Stats.MemoryTrackingEnabled) {
|
|
8
|
+
return { error: "MemoryTrackingEnabled is false on this peer" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const requested = requestData.tags as string[] | undefined;
|
|
12
|
+
const requestedSet = requested && requested.size() > 0 ? new Set(requested) : undefined;
|
|
13
|
+
|
|
14
|
+
const categories: Record<string, number> = {};
|
|
15
|
+
for (const item of Enum.DeveloperMemoryTag.GetEnumItems()) {
|
|
16
|
+
const name = item.Name;
|
|
17
|
+
if (requestedSet && !requestedSet.has(name)) continue;
|
|
18
|
+
const [ok, mb] = pcall(() => Stats.GetMemoryUsageMbForTag(item));
|
|
19
|
+
categories[name] = ok ? (mb as number) : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const unknownTags: string[] = [];
|
|
23
|
+
if (requestedSet) {
|
|
24
|
+
const known = new Set<string>();
|
|
25
|
+
for (const i of Enum.DeveloperMemoryTag.GetEnumItems()) known.add(i.Name);
|
|
26
|
+
for (const t of requestedSet) {
|
|
27
|
+
if (!known.has(t)) {
|
|
28
|
+
unknownTags.push(t);
|
|
29
|
+
categories[t] = 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const result: Record<string, unknown> = {
|
|
35
|
+
total_mb: Stats.GetTotalMemoryUsageMb(),
|
|
36
|
+
categories,
|
|
37
|
+
memory_tracking_enabled: true,
|
|
38
|
+
timestamp: DateTime.now().UnixTimestampMillis,
|
|
39
|
+
};
|
|
40
|
+
if (unknownTags.size() > 0) result.unknown_tags = unknownTags;
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export = { getMemoryBreakdown };
|
|
@@ -96,8 +96,23 @@ function searchFiles(requestData: Record<string, unknown>) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function getPlaceInfo(_requestData: Record<string, unknown>) {
|
|
99
|
+
const dataModelName = game.Name;
|
|
100
|
+
let placeName = dataModelName;
|
|
101
|
+
|
|
102
|
+
if (game.PlaceId > 0) {
|
|
103
|
+
const MarketplaceService = game.GetService("MarketplaceService");
|
|
104
|
+
const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
|
|
105
|
+
if (ok && info !== undefined) {
|
|
106
|
+
const name = (info as { Name?: string }).Name;
|
|
107
|
+
if (typeIs(name, "string") && name !== "") {
|
|
108
|
+
placeName = name;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
return {
|
|
100
|
-
placeName
|
|
114
|
+
placeName,
|
|
115
|
+
dataModelName,
|
|
101
116
|
placeId: game.PlaceId,
|
|
102
117
|
gameId: game.GameId,
|
|
103
118
|
jobId: game.JobId,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { RunService } from "@rbxts/services";
|
|
2
|
+
import Utils from "../Utils";
|
|
3
|
+
import Recording from "../Recording";
|
|
4
|
+
|
|
5
|
+
// SerializationService:SerializeInstancesAsync / DeserializeInstancesAsync were
|
|
6
|
+
// added in engine v668 and are PluginSecurity. They are not in @rbxts/types yet,
|
|
7
|
+
// so we resolve the service through an untyped GetService cast and treat the
|
|
8
|
+
// methods as opaque (buffer in / buffer out).
|
|
9
|
+
type SerializationServiceShape = {
|
|
10
|
+
SerializeInstancesAsync(this: SerializationServiceShape, instances: Instance[]): buffer;
|
|
11
|
+
DeserializeInstancesAsync(this: SerializationServiceShape, b: buffer): Instance[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const SerializationService = (game as unknown as {
|
|
15
|
+
GetService(name: string): SerializationServiceShape;
|
|
16
|
+
}).GetService("SerializationService");
|
|
17
|
+
|
|
18
|
+
// EncodingService:Base64Encode / Base64Decode take and return `buffer` (not
|
|
19
|
+
// `string`). The signature is in @rbxts/types under None.d.ts so a normal
|
|
20
|
+
// GetService("EncodingService") would already give correct types, but @rbxts
|
|
21
|
+
// generates a per-service nominal interface and roblox.d.ts doesn't re-export
|
|
22
|
+
// EncodingService from the services barrel module - so the typed cast below
|
|
23
|
+
// matches what GetService would give us if it did.
|
|
24
|
+
type EncodingServiceShape = {
|
|
25
|
+
Base64Encode(this: EncodingServiceShape, input: buffer): buffer;
|
|
26
|
+
Base64Decode(this: EncodingServiceShape, input: buffer): buffer;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const EncodingService = (game as unknown as {
|
|
30
|
+
GetService(name: string): EncodingServiceShape;
|
|
31
|
+
}).GetService("EncodingService");
|
|
32
|
+
|
|
33
|
+
const { getInstanceByPath, getInstancePath } = Utils;
|
|
34
|
+
const { beginRecording, finishRecording } = Recording;
|
|
35
|
+
|
|
36
|
+
function exportRbxm(requestData: Record<string, unknown>): unknown {
|
|
37
|
+
const instancePaths = requestData.instance_paths as string[] | undefined;
|
|
38
|
+
if (!instancePaths || !typeIs(instancePaths, "table") || instancePaths.size() === 0) {
|
|
39
|
+
return { error: "instance_paths must be a non-empty array" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const instances: Instance[] = [];
|
|
43
|
+
for (const p of instancePaths) {
|
|
44
|
+
const inst = getInstanceByPath(p);
|
|
45
|
+
if (!inst) {
|
|
46
|
+
return { error: `instance not found: ${p}` };
|
|
47
|
+
}
|
|
48
|
+
instances.push(inst);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [serializeOk, serializeResult] = pcall(() => {
|
|
52
|
+
return SerializationService.SerializeInstancesAsync(instances);
|
|
53
|
+
});
|
|
54
|
+
if (!serializeOk) {
|
|
55
|
+
return { error: `SerializeInstancesAsync failed: ${tostring(serializeResult)}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const buf = serializeResult as buffer;
|
|
59
|
+
const [encodeOk, encodeResult] = pcall(() => EncodingService.Base64Encode(buf));
|
|
60
|
+
if (!encodeOk) {
|
|
61
|
+
return { error: `EncodingService:Base64Encode failed: ${tostring(encodeResult)}` };
|
|
62
|
+
}
|
|
63
|
+
// Base64Encode returns a buffer of ASCII bytes; convert to a Lua string so
|
|
64
|
+
// HttpService:JSONEncode (called by the harness in Communication.ts) accepts
|
|
65
|
+
// it. Base64 is by definition pure ASCII so this round-trips cleanly.
|
|
66
|
+
const base64Str = buffer.tostring(encodeResult as buffer);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
base64: base64Str,
|
|
70
|
+
instance_count: instances.size(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function importRbxm(requestData: Record<string, unknown>): unknown {
|
|
75
|
+
const b64 = requestData.base64 as string | undefined;
|
|
76
|
+
const parentPath = requestData.parent_path as string | undefined;
|
|
77
|
+
const sourceLabel = (requestData.source_label as string | undefined) ?? "unknown";
|
|
78
|
+
|
|
79
|
+
if (!b64 || !typeIs(b64, "string")) {
|
|
80
|
+
return { error: "base64 payload is required" };
|
|
81
|
+
}
|
|
82
|
+
if (!parentPath || !typeIs(parentPath, "string")) {
|
|
83
|
+
return { error: "parent_path is required" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const parentInstance = getInstanceByPath(parentPath);
|
|
87
|
+
if (!parentInstance) {
|
|
88
|
+
return { error: `parent instance not found: ${parentPath}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// b64 is an ASCII-only Lua string from the wire; lift it into a buffer for
|
|
92
|
+
// EncodingService:Base64Decode, which returns a buffer of raw rbxm bytes
|
|
93
|
+
// ready for DeserializeInstancesAsync.
|
|
94
|
+
const [b64BufOk, b64BufResult] = pcall(() => buffer.fromstring(b64));
|
|
95
|
+
if (!b64BufOk) {
|
|
96
|
+
return { error: `buffer.fromstring(base64) failed: ${tostring(b64BufResult)}` };
|
|
97
|
+
}
|
|
98
|
+
const [decodeOk, decodeResult] = pcall(() => EncodingService.Base64Decode(b64BufResult as buffer));
|
|
99
|
+
if (!decodeOk) {
|
|
100
|
+
return { error: `EncodingService:Base64Decode failed: ${tostring(decodeResult)}` };
|
|
101
|
+
}
|
|
102
|
+
const buf = decodeResult as buffer;
|
|
103
|
+
|
|
104
|
+
const [deserOk, deserResult] = pcall(() => {
|
|
105
|
+
return SerializationService.DeserializeInstancesAsync(buf);
|
|
106
|
+
});
|
|
107
|
+
if (!deserOk) {
|
|
108
|
+
return { error: `DeserializeInstancesAsync failed: ${tostring(deserResult)}` };
|
|
109
|
+
}
|
|
110
|
+
const deserialized = deserResult as Instance[];
|
|
111
|
+
|
|
112
|
+
// All-or-nothing parenting. Track every instance we've attached and roll back
|
|
113
|
+
// (unparent + Destroy) if any later one fails - partial imports leave the DM
|
|
114
|
+
// in a worse state than failing cleanly.
|
|
115
|
+
const isEdit = !RunService.IsRunning();
|
|
116
|
+
const recordingId = isEdit ? beginRecording(`Import rbxm`) : undefined;
|
|
117
|
+
|
|
118
|
+
const attached: Instance[] = [];
|
|
119
|
+
let failureMessage: string | undefined;
|
|
120
|
+
for (const inst of deserialized) {
|
|
121
|
+
const [parentOk, parentErr] = pcall(() => {
|
|
122
|
+
inst.Parent = parentInstance;
|
|
123
|
+
});
|
|
124
|
+
if (!parentOk) {
|
|
125
|
+
failureMessage = `failed to parent ${inst.Name} (${inst.ClassName}) under ${parentPath}: ${tostring(parentErr)}`;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
attached.push(inst);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (failureMessage !== undefined) {
|
|
132
|
+
for (const inst of attached) {
|
|
133
|
+
pcall(() => {
|
|
134
|
+
inst.Parent = undefined;
|
|
135
|
+
inst.Destroy();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// Also destroy any unparented deserialized instances so they don't leak.
|
|
139
|
+
for (const inst of deserialized) {
|
|
140
|
+
if (inst.Parent === undefined) {
|
|
141
|
+
pcall(() => inst.Destroy());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
finishRecording(recordingId, false);
|
|
145
|
+
return { error: failureMessage };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const names: string[] = [];
|
|
149
|
+
const paths: string[] = [];
|
|
150
|
+
for (const inst of attached) {
|
|
151
|
+
names.push(inst.Name);
|
|
152
|
+
paths.push(getInstancePath(inst));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The recording shows "MCP: Import rbxm" in Studio's undo stack -
|
|
156
|
+
// ChangeHistoryService doesn't expose a way to set a richer displayName
|
|
157
|
+
// after TryBeginRecording, so the count/source only land in the JSON response.
|
|
158
|
+
finishRecording(recordingId, true);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
instance_count: attached.size(),
|
|
162
|
+
instance_names: names,
|
|
163
|
+
instance_paths: paths,
|
|
164
|
+
parent_path: parentPath,
|
|
165
|
+
source: sourceLabel,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export = {
|
|
170
|
+
exportRbxm,
|
|
171
|
+
importRbxm,
|
|
172
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HttpService, LogService } from "@rbxts/services";
|
|
2
|
-
import { installBridges, cleanupBridges
|
|
2
|
+
import { installBridges, cleanupBridges } from "../EvalBridges";
|
|
3
3
|
|
|
4
4
|
const StudioTestService = game.GetService("StudioTestService");
|
|
5
5
|
const ServerScriptService = game.GetService("ServerScriptService");
|
|
@@ -159,7 +159,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
159
159
|
// so eval_server_runtime / eval_client_runtime work without manual setup.
|
|
160
160
|
// Bridges are cleaned up from the edit DM after the play DMs tear down.
|
|
161
161
|
const bridgeInstall = installBridges();
|
|
162
|
-
const hasLoadString = loadStringEnabled();
|
|
163
162
|
if (!bridgeInstall.installed) {
|
|
164
163
|
warn(`[MCP] Eval bridge install failed: ${bridgeInstall.error}`);
|
|
165
164
|
}
|
|
@@ -203,17 +202,6 @@ function startPlaytest(requestData: Record<string, unknown>) {
|
|
|
203
202
|
evalBridges: bridgeInstall.installed ? "installed" : `failed: ${bridgeInstall.error}`,
|
|
204
203
|
};
|
|
205
204
|
|
|
206
|
-
// Surface loadstring availability up-front so callers know whether
|
|
207
|
-
// eval_server_runtime will work before they try it. eval_client_runtime
|
|
208
|
-
// doesn't need loadstring (it uses ModuleScript+require), so this only
|
|
209
|
-
// affects the server bridge.
|
|
210
|
-
if (!hasLoadString) {
|
|
211
|
-
response.serverEvalNote =
|
|
212
|
-
"ServerScriptService.LoadStringEnabled is false. eval_server_runtime will not work " +
|
|
213
|
-
"until you enable it (ServerScriptService > Properties > LoadStringEnabled = true) " +
|
|
214
|
-
"and restart the playtest. eval_client_runtime is unaffected.";
|
|
215
|
-
}
|
|
216
|
-
|
|
217
205
|
return response;
|
|
218
206
|
}
|
|
219
207
|
|
|
@@ -13,8 +13,13 @@ UI.init(plugin);
|
|
|
13
13
|
const elements = UI.getElements();
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
const ICON_DISCONNECTED = "rbxassetid://__BUTTON_ICON_DISCONNECTED__";
|
|
17
|
+
const ICON_CONNECTING = "rbxassetid://__BUTTON_ICON_CONNECTING__";
|
|
18
|
+
const ICON_CONNECTED = "rbxassetid://__BUTTON_ICON_CONNECTED__";
|
|
19
|
+
|
|
16
20
|
const toolbar = plugin.CreateToolbar("__TOOLBAR_NAME__");
|
|
17
|
-
const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__",
|
|
21
|
+
const button = toolbar.CreateButton("__BUTTON_TITLE__", "__BUTTON_TOOLTIP__", ICON_DISCONNECTED);
|
|
22
|
+
UI.setToolbarButton(button, { disconnected: ICON_DISCONNECTED, connecting: ICON_CONNECTING, connected: ICON_CONNECTED });
|
|
18
23
|
|
|
19
24
|
|
|
20
25
|
elements.connectButton.Activated.Connect(() => {
|