@chrrxs/robloxstudio-mcp 2.11.4 → 2.13.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 +2066 -435
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +935 -298
- package/studio-plugin/src/modules/ClientBroker.ts +90 -36
- package/studio-plugin/src/modules/Communication.ts +118 -5
- package/studio-plugin/src/modules/EvalBridges.ts +60 -11
- package/studio-plugin/src/modules/LuauExec.ts +305 -0
- package/studio-plugin/src/modules/RenderMonitor.ts +60 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +67 -31
- package/studio-plugin/src/modules/handlers/CaptureHandlers.ts +45 -3
- package/studio-plugin/src/modules/handlers/InputHandlers.ts +100 -39
- package/studio-plugin/src/modules/handlers/MetadataHandlers.ts +7 -146
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +31 -8
- package/studio-plugin/src/server/index.server.ts +6 -0
- package/studio-plugin/src/types/index.d.ts +5 -2
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
import { HttpService, Players, ReplicatedStorage, RunService } from "@rbxts/services";
|
|
1
|
+
import { HttpService, Players, ReplicatedStorage, RunService, ServerStorage } from "@rbxts/services";
|
|
2
2
|
import RuntimeLogBuffer from "./RuntimeLogBuffer";
|
|
3
3
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
4
|
+
import CaptureHandlers from "./handlers/CaptureHandlers";
|
|
5
|
+
import InputHandlers from "./handlers/InputHandlers";
|
|
6
|
+
import LuauExec from "./LuauExec";
|
|
7
|
+
|
|
8
|
+
// Mirror of Communication.computeInstanceId() — duplicated here because the
|
|
9
|
+
// client broker runs in the play-server DM where it can't easily import from
|
|
10
|
+
// the edit-side module, and the place identifier must match what the edit-DM
|
|
11
|
+
// plugin reports. Both use the same algorithm against the shared DataModel.
|
|
12
|
+
function computeInstanceId(): string {
|
|
13
|
+
if (game.PlaceId !== 0) {
|
|
14
|
+
return `place:${tostring(game.PlaceId)}`;
|
|
15
|
+
}
|
|
16
|
+
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
17
|
+
if (typeIs(existing, "string") && existing !== "") {
|
|
18
|
+
return `anon:${existing as string}`;
|
|
19
|
+
}
|
|
20
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
21
|
+
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
22
|
+
return `anon:${fresh}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let cachedPlaceName: string | undefined;
|
|
26
|
+
function resolvePlaceName(): string {
|
|
27
|
+
if (cachedPlaceName !== undefined) return cachedPlaceName;
|
|
28
|
+
if (game.PlaceId === 0) {
|
|
29
|
+
cachedPlaceName = game.Name;
|
|
30
|
+
return cachedPlaceName;
|
|
31
|
+
}
|
|
32
|
+
const MarketplaceService = game.GetService("MarketplaceService");
|
|
33
|
+
const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
|
|
34
|
+
if (ok && info !== undefined) {
|
|
35
|
+
const name = (info as { Name?: string }).Name;
|
|
36
|
+
if (typeIs(name, "string") && name !== "") {
|
|
37
|
+
cachedPlaceName = name;
|
|
38
|
+
return cachedPlaceName;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return game.Name;
|
|
42
|
+
}
|
|
4
43
|
|
|
5
44
|
// The client peer cannot reach the MCP HTTP server - Roblox forbids
|
|
6
45
|
// HttpService:RequestAsync from the client DM even under PluginSecurity, and
|
|
@@ -18,7 +57,7 @@ const MCP_URL = "http://localhost:58741";
|
|
|
18
57
|
const BROKER_NAME = "__MCPClientBroker";
|
|
19
58
|
|
|
20
59
|
interface ProxyEntry {
|
|
21
|
-
|
|
60
|
+
pluginSessionId: string;
|
|
22
61
|
role: string;
|
|
23
62
|
}
|
|
24
63
|
|
|
@@ -31,12 +70,6 @@ interface BrokerEnvelope {
|
|
|
31
70
|
code?: string;
|
|
32
71
|
}
|
|
33
72
|
|
|
34
|
-
interface ExecuteResult {
|
|
35
|
-
success: boolean;
|
|
36
|
-
returnValue?: string;
|
|
37
|
-
message?: string;
|
|
38
|
-
error?: string;
|
|
39
|
-
}
|
|
40
73
|
|
|
41
74
|
// Endpoints the server-peer broker is allowed to forward to the client peer.
|
|
42
75
|
// Each requires the client peer's plugin VM (because the buffer / require
|
|
@@ -45,6 +78,13 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
|
|
|
45
78
|
"/api/execute-luau",
|
|
46
79
|
"/api/get-runtime-logs",
|
|
47
80
|
"/api/get-memory-breakdown",
|
|
81
|
+
// Screenshot capture must run in the client peer (CaptureService captures
|
|
82
|
+
// the play viewport there); the edit DM reads the temp id back separately.
|
|
83
|
+
"/api/capture-begin",
|
|
84
|
+
// Virtual input (CreateVirtualInput) drives the running client's input
|
|
85
|
+
// pipeline, so it must execute in the client peer's VM.
|
|
86
|
+
"/api/simulate-mouse-input",
|
|
87
|
+
"/api/simulate-keyboard-input",
|
|
48
88
|
]);
|
|
49
89
|
|
|
50
90
|
interface ReadyResponseBody {
|
|
@@ -72,7 +112,17 @@ function reRegisterProxy(proxyId: string, role: string): void {
|
|
|
72
112
|
const last = lastReadyByProxy.get(proxyId) ?? 0;
|
|
73
113
|
if (now - last < 2) return;
|
|
74
114
|
lastReadyByProxy.set(proxyId, now);
|
|
75
|
-
pcall(() =>
|
|
115
|
+
pcall(() =>
|
|
116
|
+
postJson("/ready", {
|
|
117
|
+
pluginSessionId: proxyId,
|
|
118
|
+
instanceId: computeInstanceId(),
|
|
119
|
+
role,
|
|
120
|
+
placeId: game.PlaceId,
|
|
121
|
+
placeName: resolvePlaceName(),
|
|
122
|
+
dataModelName: game.Name,
|
|
123
|
+
isRunning: RunService.IsRunning(),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
76
126
|
}
|
|
77
127
|
|
|
78
128
|
function forkRole(): "edit" | "server" | "client" {
|
|
@@ -92,31 +142,16 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
|
92
142
|
);
|
|
93
143
|
}
|
|
94
144
|
|
|
95
|
-
function handleExecuteLuau(data: Record<string, unknown> | undefined)
|
|
145
|
+
function handleExecuteLuau(data: Record<string, unknown> | undefined) {
|
|
96
146
|
const code = data && (data.code as string | undefined);
|
|
97
147
|
if (typeIs(code, "string") === false || code === "") {
|
|
98
148
|
return { success: false, error: "code is required" };
|
|
99
149
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!okSet) {
|
|
106
|
-
m.Destroy();
|
|
107
|
-
return { success: false, error: `Source set failed: ${tostring(setErr)}` };
|
|
108
|
-
}
|
|
109
|
-
m.Parent = game.Workspace;
|
|
110
|
-
const [okReq, result] = pcall(() => require(m));
|
|
111
|
-
m.Destroy();
|
|
112
|
-
if (okReq) {
|
|
113
|
-
return {
|
|
114
|
-
success: true,
|
|
115
|
-
returnValue: result !== undefined ? tostring(result) : undefined,
|
|
116
|
-
message: "Code executed successfully",
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
return { success: false, error: tostring(result) };
|
|
150
|
+
// Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
|
|
151
|
+
// wrapper (so `print("hi")` with no return doesn't fail the
|
|
152
|
+
// ModuleScript's "must return one value" rule) and JSON-encodes table
|
|
153
|
+
// returns instead of yielding "table: 0xaddr".
|
|
154
|
+
return LuauExec.execute(code as string);
|
|
120
155
|
}
|
|
121
156
|
|
|
122
157
|
function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknown {
|
|
@@ -148,6 +183,15 @@ function setupClientBroker() {
|
|
|
148
183
|
if (payload && payload.endpoint === "/api/get-memory-breakdown") {
|
|
149
184
|
return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
|
|
150
185
|
}
|
|
186
|
+
if (payload && payload.endpoint === "/api/capture-begin") {
|
|
187
|
+
return CaptureHandlers.captureBegin();
|
|
188
|
+
}
|
|
189
|
+
if (payload && payload.endpoint === "/api/simulate-mouse-input") {
|
|
190
|
+
return InputHandlers.simulateMouseInput(payload.data ?? {});
|
|
191
|
+
}
|
|
192
|
+
if (payload && payload.endpoint === "/api/simulate-keyboard-input") {
|
|
193
|
+
return InputHandlers.simulateKeyboardInput(payload.data ?? {});
|
|
194
|
+
}
|
|
151
195
|
if (payload && payload.endpoint === "/api/execute-luau") {
|
|
152
196
|
return handleExecuteLuau(payload.data);
|
|
153
197
|
}
|
|
@@ -162,7 +206,7 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
|
162
206
|
while (player.Parent !== undefined && proxyByPlayer.has(player)) {
|
|
163
207
|
const [ok, res] = pcall(() =>
|
|
164
208
|
HttpService.RequestAsync({
|
|
165
|
-
Url: `${MCP_URL}/poll?
|
|
209
|
+
Url: `${MCP_URL}/poll?pluginSessionId=${proxyId}`,
|
|
166
210
|
Method: "GET",
|
|
167
211
|
Headers: { "Content-Type": "application/json" },
|
|
168
212
|
}),
|
|
@@ -189,10 +233,12 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
|
189
233
|
response = { success: false, error: `InvokeClient failed: ${tostring(invokeRes)}` };
|
|
190
234
|
}
|
|
191
235
|
} else {
|
|
236
|
+
const allowed: string[] = [];
|
|
237
|
+
for (const ep of CLIENT_BROKER_ALLOWED_ENDPOINTS) allowed.push(ep);
|
|
192
238
|
response = {
|
|
193
239
|
error:
|
|
194
240
|
`Client-proxy does not forward ${tostring(request.endpoint)}. ` +
|
|
195
|
-
`Allowed:
|
|
241
|
+
`Allowed: ${allowed.join(", ")}.`,
|
|
196
242
|
};
|
|
197
243
|
}
|
|
198
244
|
postJson("/response", { requestId: body.requestId, response });
|
|
@@ -206,14 +252,22 @@ function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
|
206
252
|
function registerProxy(player: Player, rf: RemoteFunction) {
|
|
207
253
|
if (proxyByPlayer.has(player)) return;
|
|
208
254
|
const proxyId = HttpService.GenerateGUID(false);
|
|
209
|
-
const [ok, res] = postJson("/ready", {
|
|
255
|
+
const [ok, res] = postJson("/ready", {
|
|
256
|
+
pluginSessionId: proxyId,
|
|
257
|
+
instanceId: computeInstanceId(),
|
|
258
|
+
role: "client",
|
|
259
|
+
placeId: game.PlaceId,
|
|
260
|
+
placeName: resolvePlaceName(),
|
|
261
|
+
dataModelName: game.Name,
|
|
262
|
+
isRunning: RunService.IsRunning(),
|
|
263
|
+
});
|
|
210
264
|
if (!ok || !res || !res.Success) {
|
|
211
265
|
warn(`[MCPFork] proxy register failed for ${player.Name}`);
|
|
212
266
|
return;
|
|
213
267
|
}
|
|
214
268
|
const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
|
|
215
269
|
const assigned = body.assignedRole ?? "client";
|
|
216
|
-
proxyByPlayer.set(player, {
|
|
270
|
+
proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
|
|
217
271
|
task.spawn(pollProxy, proxyId, player, rf);
|
|
218
272
|
}
|
|
219
273
|
|
|
@@ -238,12 +292,12 @@ function setupServerBroker() {
|
|
|
238
292
|
const entry = proxyByPlayer.get(p);
|
|
239
293
|
if (entry) {
|
|
240
294
|
proxyByPlayer.delete(p);
|
|
241
|
-
postJson("/disconnect", {
|
|
295
|
+
postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
|
|
242
296
|
}
|
|
243
297
|
});
|
|
244
298
|
game.BindToClose(() => {
|
|
245
299
|
for (const [, entry] of proxyByPlayer) {
|
|
246
|
-
postJson("/disconnect", {
|
|
300
|
+
postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
|
|
247
301
|
}
|
|
248
302
|
proxyByPlayer.clear();
|
|
249
303
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { HttpService, RunService } from "@rbxts/services";
|
|
1
|
+
import { HttpService, RunService, ServerStorage } from "@rbxts/services";
|
|
2
2
|
import State from "./State";
|
|
3
3
|
import Utils from "./Utils";
|
|
4
4
|
import UI from "./UI";
|
|
5
|
+
import { ensureBridgesInstalled } from "./EvalBridges";
|
|
5
6
|
import QueryHandlers from "./handlers/QueryHandlers";
|
|
6
7
|
import PropertyHandlers from "./handlers/PropertyHandlers";
|
|
7
8
|
import InstanceHandlers from "./handlers/InstanceHandlers";
|
|
@@ -17,8 +18,60 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
|
17
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
18
19
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
22
|
+
// can tell our polls apart from any other plugin's polls. Not user-facing —
|
|
23
|
+
// MCP tools and the LLM operate on instanceId (the place identifier).
|
|
24
|
+
const pluginSessionId = HttpService.GenerateGUID(false);
|
|
25
|
+
|
|
26
|
+
// Place-level identifier shared by every plugin running in DataModels of
|
|
27
|
+
// the same place file (edit DM + playtest server DM + playtest clients).
|
|
28
|
+
// Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
|
|
29
|
+
// places where the UUID lives on ServerStorage's __MCPPlaceId attribute
|
|
30
|
+
// and travels with the .rbxl.
|
|
31
|
+
const MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId";
|
|
32
|
+
|
|
33
|
+
function computeInstanceId(): string {
|
|
34
|
+
if (game.PlaceId !== 0) {
|
|
35
|
+
return `place:${tostring(game.PlaceId)}`;
|
|
36
|
+
}
|
|
37
|
+
const existing = ServerStorage.GetAttribute(MCP_PLACE_ID_ATTRIBUTE);
|
|
38
|
+
if (typeIs(existing, "string") && existing !== "") {
|
|
39
|
+
return `anon:${existing as string}`;
|
|
40
|
+
}
|
|
41
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
42
|
+
pcall(() => ServerStorage.SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh));
|
|
43
|
+
return `anon:${fresh}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const instanceId = computeInstanceId();
|
|
21
47
|
let assignedRole: string | undefined;
|
|
48
|
+
let duplicateInstanceRole = false;
|
|
49
|
+
|
|
50
|
+
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
51
|
+
// /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
52
|
+
// from game.Name (the DataModel name, often "Place1" in edit). We only fetch
|
|
53
|
+
// once per plugin load; the published name doesn't change mid-session.
|
|
54
|
+
let cachedPlaceName: string | undefined;
|
|
55
|
+
|
|
56
|
+
function resolvePlaceName(): string {
|
|
57
|
+
if (cachedPlaceName !== undefined) return cachedPlaceName;
|
|
58
|
+
if (game.PlaceId === 0) {
|
|
59
|
+
cachedPlaceName = game.Name;
|
|
60
|
+
return cachedPlaceName;
|
|
61
|
+
}
|
|
62
|
+
const MarketplaceService = game.GetService("MarketplaceService");
|
|
63
|
+
const [ok, info] = pcall(() => MarketplaceService.GetProductInfo(game.PlaceId));
|
|
64
|
+
if (ok && info !== undefined) {
|
|
65
|
+
const name = (info as { Name?: string }).Name;
|
|
66
|
+
if (typeIs(name, "string") && name !== "") {
|
|
67
|
+
cachedPlaceName = name;
|
|
68
|
+
return cachedPlaceName;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Don't cache failures — could be transient (offline, rate-limited).
|
|
72
|
+
// Next /ready will retry. Return game.Name as fallback.
|
|
73
|
+
return game.Name;
|
|
74
|
+
}
|
|
22
75
|
|
|
23
76
|
function detectRole(): string {
|
|
24
77
|
if (!RunService.IsRunning()) return "edit";
|
|
@@ -91,6 +144,8 @@ const routeMap: Record<string, Handler> = {
|
|
|
91
144
|
"/api/preview-asset": AssetHandlers.previewAsset,
|
|
92
145
|
|
|
93
146
|
"/api/capture-screenshot": CaptureHandlers.captureScreenshot,
|
|
147
|
+
"/api/capture-begin": CaptureHandlers.captureBegin,
|
|
148
|
+
"/api/capture-read": CaptureHandlers.captureRead,
|
|
94
149
|
"/api/simulate-mouse-input": InputHandlers.simulateMouseInput,
|
|
95
150
|
"/api/simulate-keyboard-input": InputHandlers.simulateKeyboardInput,
|
|
96
151
|
|
|
@@ -140,7 +195,25 @@ function getConnectionStatus(connIndex: number): string {
|
|
|
140
195
|
// restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
|
|
141
196
|
let lastReadyPostAt = 0;
|
|
142
197
|
|
|
198
|
+
// game.Name is sometimes "Place1" at plugin-load time and only settles to
|
|
199
|
+
// the real DataModel name (e.g. "Game" once playtest spawns the play DM)
|
|
200
|
+
// after Studio finishes wiring things up. Re-fire /ready when it changes so
|
|
201
|
+
// get_connected_instances doesn't show a stale dataModelName forever. Set
|
|
202
|
+
// up once per plugin load — the connection passed in is whichever was
|
|
203
|
+
// active when activatePlugin was first called.
|
|
204
|
+
let nameChangeConn: RBXScriptConnection | undefined;
|
|
205
|
+
function ensureNameChangeWatcher(conn: Connection): void {
|
|
206
|
+
if (nameChangeConn) return;
|
|
207
|
+
const [okSig, signal] = pcall(() => game.GetPropertyChangedSignal("Name"));
|
|
208
|
+
if (!okSig || !signal) return;
|
|
209
|
+
nameChangeConn = signal.Connect(() => {
|
|
210
|
+
// sendReady has its own 2s throttle, so rapid burst changes coalesce.
|
|
211
|
+
sendReady(conn);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
143
215
|
function sendReady(conn: Connection): void {
|
|
216
|
+
if (duplicateInstanceRole) return; // stop retrying once the server has rejected us
|
|
144
217
|
const now = tick();
|
|
145
218
|
if (now - lastReadyPostAt < 2) return; // throttle to ≤1 /ready every 2s
|
|
146
219
|
lastReadyPostAt = now;
|
|
@@ -151,14 +224,36 @@ function sendReady(conn: Connection): void {
|
|
|
151
224
|
Method: "POST",
|
|
152
225
|
Headers: { "Content-Type": "application/json" },
|
|
153
226
|
Body: HttpService.JSONEncode({
|
|
227
|
+
pluginSessionId,
|
|
154
228
|
instanceId,
|
|
155
229
|
role: detectRole(),
|
|
230
|
+
placeId: game.PlaceId,
|
|
231
|
+
placeName: resolvePlaceName(),
|
|
232
|
+
dataModelName: game.Name,
|
|
233
|
+
isRunning: RunService.IsRunning(),
|
|
156
234
|
pluginReady: true,
|
|
157
235
|
timestamp: tick(),
|
|
158
236
|
}),
|
|
159
237
|
});
|
|
160
238
|
});
|
|
161
|
-
if (readyOk
|
|
239
|
+
if (!readyOk) return;
|
|
240
|
+
// 409 = duplicate_instance_role. Surface in UI and stop polling.
|
|
241
|
+
if (readyResult.StatusCode === 409) {
|
|
242
|
+
duplicateInstanceRole = true;
|
|
243
|
+
conn.isActive = false;
|
|
244
|
+
const ui = UI.getElements();
|
|
245
|
+
if (State.getActiveTabIndex() === 0) {
|
|
246
|
+
ui.statusLabel.Text = "Duplicate instance";
|
|
247
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
248
|
+
ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role";
|
|
249
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
250
|
+
}
|
|
251
|
+
warn(
|
|
252
|
+
`[MCPPlugin] Another Studio is already connected as (${instanceId}, ${detectRole()}). Close the other Studio window or this one.`,
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (readyResult.Success) {
|
|
162
257
|
const [parseOk, readyData] = pcall(
|
|
163
258
|
() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
|
|
164
259
|
);
|
|
@@ -178,7 +273,7 @@ function pollForRequests(connIndex: number) {
|
|
|
178
273
|
|
|
179
274
|
const [success, result] = pcall(() => {
|
|
180
275
|
return HttpService.RequestAsync({
|
|
181
|
-
Url: `${conn.serverUrl}/poll?
|
|
276
|
+
Url: `${conn.serverUrl}/poll?pluginSessionId=${pluginSessionId}`,
|
|
182
277
|
Method: "GET",
|
|
183
278
|
Headers: { "Content-Type": "application/json" },
|
|
184
279
|
});
|
|
@@ -365,6 +460,24 @@ function activatePlugin(connIndex?: number) {
|
|
|
365
460
|
// Initial /ready; pollForRequests will also re-fire ready if the server
|
|
366
461
|
// later reports knownInstance=false (process restart, etc).
|
|
367
462
|
sendReady(conn);
|
|
463
|
+
|
|
464
|
+
// Keep the eval bridges present in the edit DM so that ANY playtest —
|
|
465
|
+
// including one the dev starts manually via the Studio Play button —
|
|
466
|
+
// clones them into the play DMs and eval_*_runtime works with no setup
|
|
467
|
+
// roundtrip. Only the edit DM installs; play DMs already have the cloned
|
|
468
|
+
// copies. Idempotent, so reconnects don't re-dirty the place.
|
|
469
|
+
if (!RunService.IsRunning()) {
|
|
470
|
+
task.spawn(() => {
|
|
471
|
+
const result = ensureBridgesInstalled();
|
|
472
|
+
if (!result.installed) {
|
|
473
|
+
warn(`[MCPPlugin] Eval bridge install failed: ${result.error}`);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Watch for game.Name updates so a stale "Place1" captured at first
|
|
479
|
+
// /ready gets refreshed once Studio settles on the real DM name.
|
|
480
|
+
ensureNameChangeWatcher(conn);
|
|
368
481
|
}
|
|
369
482
|
|
|
370
483
|
function deactivatePlugin(connIndex?: number) {
|
|
@@ -383,7 +496,7 @@ function deactivatePlugin(connIndex?: number) {
|
|
|
383
496
|
Url: `${conn.serverUrl}/disconnect`,
|
|
384
497
|
Method: "POST",
|
|
385
498
|
Headers: { "Content-Type": "application/json" },
|
|
386
|
-
Body: HttpService.JSONEncode({
|
|
499
|
+
Body: HttpService.JSONEncode({ pluginSessionId, timestamp: tick() }),
|
|
387
500
|
});
|
|
388
501
|
});
|
|
389
502
|
|
|
@@ -21,11 +21,16 @@
|
|
|
21
21
|
// ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
|
|
22
22
|
// when LoadStringEnabled=false (the default in fresh places).
|
|
23
23
|
//
|
|
24
|
-
// Lifecycle:
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
24
|
+
// Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
|
|
25
|
+
// installs them (ensureBridgesInstalled) when the plugin connects in edit,
|
|
26
|
+
// and TestHandlers.startPlaytest force-refreshes them right before
|
|
27
|
+
// ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
|
|
28
|
+
// play DMs, so the scripts come along and run there. We keep them in the edit
|
|
29
|
+
// DM after a playtest ends (rather than cleaning up) so that a playtest the
|
|
30
|
+
// dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
|
|
31
|
+
// tool — also gets the bridges cloned in. This is intentionally a little
|
|
32
|
+
// intrusive (two helper scripts visible in Explorer) in exchange for a
|
|
33
|
+
// zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
|
|
29
34
|
//
|
|
30
35
|
// Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
|
|
31
36
|
// with Archivable=false (verified empirically in v2.9.0 testing - bridges
|
|
@@ -61,9 +66,9 @@ export const BRIDGE_NAMES = {
|
|
|
61
66
|
// Embedded Luau. The double `${...}` references our exported names so a
|
|
62
67
|
// rename here propagates to both the script source and the tool wrappers.
|
|
63
68
|
const SERVER_BRIDGE_SOURCE = `
|
|
64
|
-
--
|
|
65
|
-
--
|
|
66
|
-
--
|
|
69
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP
|
|
70
|
+
-- tool (shared-require-cache eval on the server during playtests). Inert
|
|
71
|
+
-- outside Studio (no-ops in live games); safe to leave in place.
|
|
67
72
|
|
|
68
73
|
local ServerScriptService = game:GetService("ServerScriptService")
|
|
69
74
|
local RunService = game:GetService("RunService")
|
|
@@ -88,9 +93,9 @@ end
|
|
|
88
93
|
`;
|
|
89
94
|
|
|
90
95
|
const CLIENT_BRIDGE_SOURCE = `
|
|
91
|
-
--
|
|
92
|
-
--
|
|
93
|
-
--
|
|
96
|
+
-- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP
|
|
97
|
+
-- tool (shared-require-cache eval on the client during playtests). Inert
|
|
98
|
+
-- outside Studio (no-ops in live games); safe to leave in place.
|
|
94
99
|
|
|
95
100
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
|
96
101
|
local RunService = game:GetService("RunService")
|
|
@@ -114,6 +119,28 @@ bf.OnInvoke = function(payload)
|
|
|
114
119
|
end
|
|
115
120
|
`;
|
|
116
121
|
|
|
122
|
+
// Stamp written onto each installed bridge Script so we can tell whether the
|
|
123
|
+
// bridge currently in the DM was produced by THIS plugin build. It's a djb2
|
|
124
|
+
// hash of the actual bridge source plus the plugin version, so ANY change to
|
|
125
|
+
// the source (or a version bump) yields a new stamp — which makes
|
|
126
|
+
// ensureBridgesInstalled() force a refresh on the next plugin load instead of
|
|
127
|
+
// keeping a stale bridge that happens to still be present (e.g. one saved into
|
|
128
|
+
// the .rbxl from an older build).
|
|
129
|
+
const STAMP_ATTR = "__MCPBridgeStamp";
|
|
130
|
+
|
|
131
|
+
function computeBridgeStamp(): string {
|
|
132
|
+
const combined = `${SERVER_BRIDGE_SOURCE}|${CLIENT_BRIDGE_SOURCE}`;
|
|
133
|
+
let h = 5381;
|
|
134
|
+
for (let i = 1; i <= combined.size(); i++) {
|
|
135
|
+
h = (h * 33 + string.byte(combined, i)[0]) % 2147483647;
|
|
136
|
+
}
|
|
137
|
+
// "__VERSION__" is replaced with the package version at package time
|
|
138
|
+
// (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
139
|
+
return `${tostring(h)}-__VERSION__`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const BRIDGE_STAMP = computeBridgeStamp();
|
|
143
|
+
|
|
117
144
|
function setSource(scriptInst: Script | LocalScript, source: string): void {
|
|
118
145
|
// ScriptEditorService is the cleaner API and integrates with Studio's
|
|
119
146
|
// edit history; fall back to direct Source mutation (allowed in plugin
|
|
@@ -144,6 +171,26 @@ export function cleanupBridges(): void {
|
|
|
144
171
|
}
|
|
145
172
|
}
|
|
146
173
|
|
|
174
|
+
// Idempotent variant: install only if the bridge scripts aren't already
|
|
175
|
+
// present in the edit DM. Used to keep the bridges always available (so a
|
|
176
|
+
// playtest the dev starts manually — not via the MCP start_playtest tool —
|
|
177
|
+
// still clones them into the play DMs). Cheap no-op when already installed,
|
|
178
|
+
// which avoids re-dirtying the place on every plugin reconnect.
|
|
179
|
+
export function ensureBridgesInstalled(): { installed: boolean; error?: string } {
|
|
180
|
+
const { server, client } = findBridges();
|
|
181
|
+
if (server && client) {
|
|
182
|
+
// Both present — but only skip the reinstall if they were produced by
|
|
183
|
+
// THIS build. A mismatched/absent stamp means a stale bridge (older
|
|
184
|
+
// plugin, or one persisted in the saved place), so force a refresh.
|
|
185
|
+
const sStamp = server.GetAttribute(STAMP_ATTR);
|
|
186
|
+
const cStamp = client.GetAttribute(STAMP_ATTR);
|
|
187
|
+
if (sStamp === BRIDGE_STAMP && cStamp === BRIDGE_STAMP) {
|
|
188
|
+
return { installed: true };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return installBridges();
|
|
192
|
+
}
|
|
193
|
+
|
|
147
194
|
export function installBridges(): { installed: boolean; error?: string } {
|
|
148
195
|
// Defensive: clear any stale bridges from a prior unclean exit before
|
|
149
196
|
// inserting fresh. The injected script also self-cleans its
|
|
@@ -158,6 +205,7 @@ export function installBridges(): { installed: boolean; error?: string } {
|
|
|
158
205
|
// script. cleanupBridges() removes it from the edit DM when the
|
|
159
206
|
// playtest ends.
|
|
160
207
|
setSource(serverScript, SERVER_BRIDGE_SOURCE);
|
|
208
|
+
serverScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
|
|
161
209
|
serverScript.Parent = ServerScriptService;
|
|
162
210
|
|
|
163
211
|
const sps = getStarterPlayerScripts();
|
|
@@ -167,6 +215,7 @@ export function installBridges(): { installed: boolean; error?: string } {
|
|
|
167
215
|
const clientScript = new Instance("LocalScript");
|
|
168
216
|
clientScript.Name = CLIENT_SCRIPT_NAME;
|
|
169
217
|
setSource(clientScript, CLIENT_BRIDGE_SOURCE);
|
|
218
|
+
clientScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
|
|
170
219
|
clientScript.Parent = sps;
|
|
171
220
|
});
|
|
172
221
|
|