@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
|
@@ -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
|
+
};
|