@chrrxs/robloxstudio-mcp 2.15.2 → 2.16.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 +1288 -60
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +616 -236
- package/studio-plugin/MCPPlugin.rbxmx +616 -236
- package/studio-plugin/src/modules/ClientBroker.ts +32 -6
- package/studio-plugin/src/modules/Communication.ts +44 -34
- package/studio-plugin/src/modules/EvalBridges.ts +91 -64
- package/studio-plugin/src/modules/HttpDiagnostics.ts +50 -0
- package/studio-plugin/src/modules/ServerUrlSettings.ts +48 -0
- package/studio-plugin/src/modules/StopPlayMonitor.ts +152 -35
- package/studio-plugin/src/modules/handlers/EvalRuntimeHandlers.ts +34 -6
- package/studio-plugin/src/modules/handlers/TestHandlers.ts +20 -46
- package/studio-plugin/src/server/index.server.ts +41 -11
|
@@ -7,6 +7,7 @@ import InputHandlers from "./handlers/InputHandlers";
|
|
|
7
7
|
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
8
8
|
import LuauExec from "./LuauExec";
|
|
9
9
|
import State from "./State";
|
|
10
|
+
import HttpDiagnostics from "./HttpDiagnostics";
|
|
10
11
|
|
|
11
12
|
interface StudioTestServiceMultiplayer extends StudioTestService {
|
|
12
13
|
CanLeaveTest(): boolean;
|
|
@@ -64,7 +65,8 @@ function resolvePlaceName(): string {
|
|
|
64
65
|
// is gone: stop now uses StopPlayMonitor with plugin:SetSetting cross-DM
|
|
65
66
|
// signaling, which works regardless of MCP server state.)
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
+
const DEFAULT_MCP_URL = "http://localhost:58741";
|
|
69
|
+
let mcpUrl = DEFAULT_MCP_URL;
|
|
68
70
|
const BROKER_NAME = "__MCPClientBroker";
|
|
69
71
|
const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
|
|
70
72
|
|
|
@@ -152,7 +154,7 @@ function forkRole(): "edit" | "server" | "client" {
|
|
|
152
154
|
function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
153
155
|
return pcall(() =>
|
|
154
156
|
HttpService.RequestAsync({
|
|
155
|
-
Url: `${
|
|
157
|
+
Url: `${mcpUrl}${endpoint}`,
|
|
156
158
|
Method: "POST",
|
|
157
159
|
Headers: { "Content-Type": "application/json" },
|
|
158
160
|
Body: HttpService.JSONEncode(body),
|
|
@@ -160,6 +162,20 @@ function postJson(endpoint: string, body: Record<string, unknown>) {
|
|
|
160
162
|
);
|
|
161
163
|
}
|
|
162
164
|
|
|
165
|
+
function formatPostJsonFailure(endpoint: string, ok: boolean, res: unknown): string {
|
|
166
|
+
return HttpDiagnostics.formatRequestFailure(`${mcpUrl}${endpoint}`, ok, res);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setServerUrl(serverUrl: string | undefined): void {
|
|
170
|
+
if (serverUrl !== undefined && serverUrl !== "") {
|
|
171
|
+
mcpUrl = serverUrl;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getServerUrl(): string {
|
|
176
|
+
return mcpUrl;
|
|
177
|
+
}
|
|
178
|
+
|
|
163
179
|
function handleExecuteLuau(data: Record<string, unknown> | undefined) {
|
|
164
180
|
const code = data && (data.code as string | undefined);
|
|
165
181
|
if (typeIs(code, "string") === false || code === "") {
|
|
@@ -277,13 +293,14 @@ function setupClientBroker() {
|
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
const proxyByPlayer = new Map<Player, ProxyEntry>();
|
|
296
|
+
const proxyRegisterFailuresByPlayer = new Set<Player>();
|
|
280
297
|
let serverBrokerStarted = false;
|
|
281
298
|
|
|
282
299
|
function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
|
|
283
300
|
while (player.Parent !== undefined && proxyByPlayer.has(player)) {
|
|
284
301
|
const [ok, res] = pcall(() =>
|
|
285
302
|
HttpService.RequestAsync({
|
|
286
|
-
Url: `${
|
|
303
|
+
Url: `${mcpUrl}/poll?pluginSessionId=${proxyId}`,
|
|
287
304
|
Method: "GET",
|
|
288
305
|
Headers: { "Content-Type": "application/json" },
|
|
289
306
|
}),
|
|
@@ -341,18 +358,23 @@ function registerProxy(player: Player, rf: RemoteFunction) {
|
|
|
341
358
|
pluginVariant: State.PLUGIN_VARIANT,
|
|
342
359
|
});
|
|
343
360
|
if (!ok || !res || !res.Success) {
|
|
344
|
-
|
|
361
|
+
proxyRegisterFailuresByPlayer.add(player);
|
|
362
|
+
warn(`[robloxstudio-mcp] proxy register failed for ${player.Name}: ${formatPostJsonFailure("/ready", ok, res)}`);
|
|
345
363
|
return;
|
|
346
364
|
}
|
|
347
365
|
const body = HttpService.JSONDecode(res.Body) as ReadyResponseBody;
|
|
348
366
|
const assigned = body.assignedRole ?? "client";
|
|
349
367
|
proxyByPlayer.set(player, { pluginSessionId: proxyId, role: assigned });
|
|
368
|
+
if (proxyRegisterFailuresByPlayer.has(player)) {
|
|
369
|
+
proxyRegisterFailuresByPlayer.delete(player);
|
|
370
|
+
print(`[robloxstudio-mcp] proxy registered for ${player.Name} as ${assigned} via ${mcpUrl}`);
|
|
371
|
+
}
|
|
350
372
|
task.spawn(pollProxy, proxyId, player, rf);
|
|
351
373
|
}
|
|
352
374
|
|
|
353
375
|
// (Removed: startEditProxyLoop. The play-server DM no longer registers an
|
|
354
376
|
// "edit-proxy" peer with the MCP server. stop_playtest now uses a cross-DM
|
|
355
|
-
// plugin:SetSetting
|
|
377
|
+
// plugin:SetSetting request consumed by StopPlayMonitor in the play-server DM,
|
|
356
378
|
// which doesn't depend on MCP server state or peer registration at all.)
|
|
357
379
|
|
|
358
380
|
function setupServerBroker() {
|
|
@@ -377,6 +399,7 @@ function setupServerBroker() {
|
|
|
377
399
|
const entry = proxyByPlayer.get(p);
|
|
378
400
|
if (entry) {
|
|
379
401
|
proxyByPlayer.delete(p);
|
|
402
|
+
proxyRegisterFailuresByPlayer.delete(p);
|
|
380
403
|
postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
|
|
381
404
|
}
|
|
382
405
|
});
|
|
@@ -389,7 +412,10 @@ function setupServerBroker() {
|
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
export = {
|
|
392
|
-
MCP_URL,
|
|
415
|
+
MCP_URL: DEFAULT_MCP_URL,
|
|
416
|
+
DEFAULT_MCP_URL,
|
|
417
|
+
getServerUrl,
|
|
418
|
+
setServerUrl,
|
|
393
419
|
forkRole,
|
|
394
420
|
setupClientBroker,
|
|
395
421
|
setupServerBroker,
|
|
@@ -2,7 +2,7 @@ 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 {
|
|
5
|
+
import { cleanupLegacyEditBridges } from "./EvalBridges";
|
|
6
6
|
import QueryHandlers from "./handlers/QueryHandlers";
|
|
7
7
|
import PropertyHandlers from "./handlers/PropertyHandlers";
|
|
8
8
|
import InstanceHandlers from "./handlers/InstanceHandlers";
|
|
@@ -18,6 +18,8 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
|
|
|
18
18
|
import MemoryHandlers from "./handlers/MemoryHandlers";
|
|
19
19
|
import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
|
|
20
20
|
import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
|
|
21
|
+
import ServerUrlSettings from "./ServerUrlSettings";
|
|
22
|
+
import HttpDiagnostics from "./HttpDiagnostics";
|
|
21
23
|
import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
|
|
22
24
|
|
|
23
25
|
// Per-plugin-load random GUID. Used as the /poll URL param so the server
|
|
@@ -50,6 +52,7 @@ let assignedRole: string | undefined;
|
|
|
50
52
|
let duplicateInstanceRole = false;
|
|
51
53
|
let hasVersionMismatch = false;
|
|
52
54
|
let lastVersionMismatchWarningKey: string | undefined;
|
|
55
|
+
const readyFailureLogKeys = new Set<string>();
|
|
53
56
|
|
|
54
57
|
// Cache the published place name from MarketplaceService:GetProductInfo so
|
|
55
58
|
// /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
|
|
@@ -249,30 +252,44 @@ function sendReady(conn: Connection): void {
|
|
|
249
252
|
}),
|
|
250
253
|
});
|
|
251
254
|
});
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (State.getActiveTabIndex() === 0) {
|
|
259
|
-
ui.statusLabel.Text = "Duplicate instance";
|
|
260
|
-
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
261
|
-
ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role";
|
|
262
|
-
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
263
|
-
}
|
|
264
|
-
warn(
|
|
265
|
-
`[MCPPlugin] Another Studio is already connected as (${instanceId}, ${detectRole()}). Close the other Studio window or this one.`,
|
|
266
|
-
);
|
|
255
|
+
const readyUrl = `${conn.serverUrl}/ready`;
|
|
256
|
+
const readyRole = detectRole();
|
|
257
|
+
const readyLogKey = `${conn.serverUrl}|${instanceId}|${readyRole}`;
|
|
258
|
+
if (!readyOk) {
|
|
259
|
+
readyFailureLogKeys.add(readyLogKey);
|
|
260
|
+
warn(`[robloxstudio-mcp] /ready failed for ${instanceId}/${readyRole}: ${HttpDiagnostics.formatRequestFailure(readyUrl, readyOk, readyResult)}`);
|
|
267
261
|
return;
|
|
268
262
|
}
|
|
269
|
-
if (readyResult.Success) {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (
|
|
274
|
-
|
|
263
|
+
if (!readyResult.Success) {
|
|
264
|
+
const reason = HttpDiagnostics.formatRequestFailure(readyUrl, true, readyResult);
|
|
265
|
+
readyFailureLogKeys.add(readyLogKey);
|
|
266
|
+
// 409 = duplicate_instance_role. Surface in UI and stop polling.
|
|
267
|
+
if (readyResult.StatusCode === 409) {
|
|
268
|
+
duplicateInstanceRole = true;
|
|
269
|
+
conn.isActive = false;
|
|
270
|
+
const ui = UI.getElements();
|
|
271
|
+
if (State.getActiveTabIndex() === 0) {
|
|
272
|
+
ui.statusLabel.Text = "Duplicate instance";
|
|
273
|
+
ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
274
|
+
ui.detailStatusLabel.Text = reason;
|
|
275
|
+
ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68);
|
|
276
|
+
}
|
|
277
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
278
|
+
return;
|
|
275
279
|
}
|
|
280
|
+
warn(`[robloxstudio-mcp] /ready rejected for ${instanceId}/${readyRole}: ${reason}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const [parseOk, readyData] = pcall(
|
|
284
|
+
() => HttpService.JSONDecode(readyResult.Body) as ReadyResponse,
|
|
285
|
+
);
|
|
286
|
+
if (parseOk && readyData.assignedRole) {
|
|
287
|
+
assignedRole = readyData.assignedRole;
|
|
288
|
+
}
|
|
289
|
+
const connectedRole = assignedRole ?? detectRole();
|
|
290
|
+
if (readyFailureLogKeys.has(readyLogKey)) {
|
|
291
|
+
readyFailureLogKeys.delete(readyLogKey);
|
|
292
|
+
print(`[robloxstudio-mcp] /ready connected for ${instanceId}/${connectedRole} via ${conn.serverUrl}`);
|
|
276
293
|
}
|
|
277
294
|
});
|
|
278
295
|
}
|
|
@@ -313,7 +330,7 @@ function pollForRequests(connIndex: number) {
|
|
|
313
330
|
const warningKey = `${State.CURRENT_VERSION}:${serverVersion}`;
|
|
314
331
|
if (lastVersionMismatchWarningKey !== warningKey) {
|
|
315
332
|
lastVersionMismatchWarningKey = warningKey;
|
|
316
|
-
warn(`[
|
|
333
|
+
warn(`[robloxstudio-mcp] Version mismatch: Studio plugin v${State.CURRENT_VERSION} / MCP v${serverVersion}. Run npx -y @chrrxs/robloxstudio-mcp@latest --auto-install-plugin and restart Studio.`);
|
|
317
334
|
}
|
|
318
335
|
UI.showBanner("version-mismatch", `Plugin v${State.CURRENT_VERSION} / MCP v${serverVersion} mismatch`);
|
|
319
336
|
} else if (hasVersionMismatch) {
|
|
@@ -470,6 +487,7 @@ function activatePlugin(connIndex?: number) {
|
|
|
470
487
|
UI.updateTabLabel(idx);
|
|
471
488
|
UI.updateUIState();
|
|
472
489
|
}
|
|
490
|
+
ServerUrlSettings.rememberServerUrl(conn.serverUrl);
|
|
473
491
|
UI.updateTabDot(idx);
|
|
474
492
|
|
|
475
493
|
if (!conn.heartbeatConnection) {
|
|
@@ -487,18 +505,10 @@ function activatePlugin(connIndex?: number) {
|
|
|
487
505
|
// later reports knownInstance=false (process restart, etc).
|
|
488
506
|
sendReady(conn);
|
|
489
507
|
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
// clones them into the play DMs and eval_*_runtime works with no setup
|
|
493
|
-
// roundtrip. Only the edit DM installs; play DMs already have the cloned
|
|
494
|
-
// copies. Idempotent, so reconnects don't re-dirty the place.
|
|
508
|
+
// Remove legacy edit-mode eval bridge scripts from older plugin builds.
|
|
509
|
+
// Current bridges are created only in running play DataModels.
|
|
495
510
|
if (!RunService.IsRunning()) {
|
|
496
|
-
task.spawn(
|
|
497
|
-
const result = ensureBridgesInstalled();
|
|
498
|
-
if (!result.installed) {
|
|
499
|
-
warn(`[MCPPlugin] Eval bridge install failed: ${result.error}`);
|
|
500
|
-
}
|
|
501
|
-
});
|
|
511
|
+
task.spawn(cleanupLegacyEditBridges);
|
|
502
512
|
}
|
|
503
513
|
|
|
504
514
|
// Watch for game.Name updates so a stale "Place1" captured at first
|
|
@@ -21,30 +21,13 @@
|
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
34
|
-
//
|
|
35
|
-
// Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
|
|
36
|
-
// with Archivable=false (verified empirically in v2.9.0 testing - bridges
|
|
37
|
-
// never reached the play DMs because we'd set them to false). We now keep
|
|
38
|
-
// Archivable=true so the clone works, and rely on cleanupBridges() to
|
|
39
|
-
// remove the scripts from the edit DM when the test ends. The only failure
|
|
40
|
-
// mode is the user saving DURING an active playtest, which would persist
|
|
41
|
-
// the bridges to the .rbxl - that's a no-op next session because
|
|
42
|
-
// installBridges() always calls cleanupBridges() first to clear stale
|
|
43
|
-
// instances. The RemoteFunction/BindableFunction that the bridge scripts
|
|
44
|
-
// CREATE at runtime stay Archivable=false (they're runtime-only and should
|
|
45
|
-
// never appear in a save).
|
|
46
|
-
|
|
47
|
-
import { ServerScriptService, StarterPlayer } from "@rbxts/services";
|
|
24
|
+
// Lifecycle: bridge scripts are created only in running play DataModels.
|
|
25
|
+
// The server plugin peer creates the Script in runtime ServerScriptService;
|
|
26
|
+
// each client plugin peer creates its LocalScript in that client's
|
|
27
|
+
// PlayerScripts. Nothing is installed into the edit DataModel anymore.
|
|
28
|
+
// Runtime-created scripts disappear naturally when the playtest stops.
|
|
29
|
+
|
|
30
|
+
import { Players, ReplicatedStorage, RunService, ServerScriptService, StarterPlayer } from "@rbxts/services";
|
|
48
31
|
|
|
49
32
|
const ScriptEditorService = game.GetService("ScriptEditorService");
|
|
50
33
|
|
|
@@ -122,12 +105,10 @@ end
|
|
|
122
105
|
`;
|
|
123
106
|
|
|
124
107
|
// Stamp written onto each installed bridge Script so we can tell whether the
|
|
125
|
-
// bridge currently in the DM was produced by THIS plugin build.
|
|
126
|
-
// hash of the actual bridge source plus the plugin version, so ANY
|
|
127
|
-
// the source (or a version bump) yields a new stamp
|
|
128
|
-
//
|
|
129
|
-
// keeping a stale bridge that happens to still be present (e.g. one saved into
|
|
130
|
-
// the .rbxl from an older build).
|
|
108
|
+
// runtime bridge currently in the play DM was produced by THIS plugin build.
|
|
109
|
+
// It's a djb2 hash of the actual bridge source plus the plugin version, so ANY
|
|
110
|
+
// change to the source (or a version bump) yields a new stamp and triggers a
|
|
111
|
+
// runtime refresh instead of keeping a stale bridge.
|
|
131
112
|
const STAMP_ATTR = "__MCPBridgeStamp";
|
|
132
113
|
|
|
133
114
|
function computeBridgeStamp(): string {
|
|
@@ -155,7 +136,12 @@ function setSource(scriptInst: Script | LocalScript, source: string): void {
|
|
|
155
136
|
}
|
|
156
137
|
}
|
|
157
138
|
|
|
158
|
-
|
|
139
|
+
interface InstallResult {
|
|
140
|
+
installed: boolean;
|
|
141
|
+
error?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findLegacyEditBridges(): { server?: Instance; client?: Instance } {
|
|
159
145
|
const sps = getStarterPlayerScripts();
|
|
160
146
|
return {
|
|
161
147
|
server: ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME),
|
|
@@ -163,8 +149,16 @@ function findBridges(): { server?: Instance; client?: Instance } {
|
|
|
163
149
|
};
|
|
164
150
|
}
|
|
165
151
|
|
|
166
|
-
|
|
167
|
-
const
|
|
152
|
+
function destroyIfPresent(parent: Instance, name: string): void {
|
|
153
|
+
const existing = parent.FindFirstChild(name);
|
|
154
|
+
if (existing) {
|
|
155
|
+
pcall(() => existing.Destroy());
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function cleanupLegacyEditBridges(): void {
|
|
160
|
+
if (RunService.IsRunning()) return;
|
|
161
|
+
const { server, client } = findLegacyEditBridges();
|
|
168
162
|
if (server) {
|
|
169
163
|
pcall(() => server.Destroy());
|
|
170
164
|
}
|
|
@@ -173,52 +167,75 @@ export function cleanupBridges(): void {
|
|
|
173
167
|
}
|
|
174
168
|
}
|
|
175
169
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
170
|
+
function serverRuntimeBridgeReady(): boolean {
|
|
171
|
+
const scriptInst = ServerScriptService.FindFirstChild(SERVER_SCRIPT_NAME);
|
|
172
|
+
const bindable = ServerScriptService.FindFirstChild(BRIDGE_NAMES.serverLocal);
|
|
173
|
+
return scriptInst !== undefined &&
|
|
174
|
+
scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
|
|
175
|
+
bindable !== undefined &&
|
|
176
|
+
bindable.IsA("BindableFunction");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getPlayerScripts(): Instance | undefined {
|
|
180
|
+
const localPlayer = Players.LocalPlayer;
|
|
181
|
+
if (!localPlayer) return undefined;
|
|
182
|
+
let playerScripts = localPlayer.FindFirstChild("PlayerScripts");
|
|
183
|
+
if (!playerScripts) {
|
|
184
|
+
playerScripts = localPlayer.WaitForChild("PlayerScripts", 5);
|
|
192
185
|
}
|
|
193
|
-
return
|
|
186
|
+
return playerScripts;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clientRuntimeBridgeReady(): boolean {
|
|
190
|
+
const playerScripts = getPlayerScripts();
|
|
191
|
+
if (!playerScripts) return false;
|
|
192
|
+
const scriptInst = playerScripts.FindFirstChild(CLIENT_SCRIPT_NAME);
|
|
193
|
+
const bindable = ReplicatedStorage.FindFirstChild(BRIDGE_NAMES.clientLocal);
|
|
194
|
+
return scriptInst !== undefined &&
|
|
195
|
+
scriptInst.GetAttribute(STAMP_ATTR) === BRIDGE_STAMP &&
|
|
196
|
+
bindable !== undefined &&
|
|
197
|
+
bindable.IsA("BindableFunction");
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// inserting fresh. The injected script also self-cleans its
|
|
199
|
-
// ReplicatedStorage/ServerScriptService children at startup, but the
|
|
200
|
-
// containing Script/LocalScript objects themselves we must clear here.
|
|
201
|
-
cleanupBridges();
|
|
200
|
+
function installServerRuntimeBridge(): InstallResult {
|
|
201
|
+
if (serverRuntimeBridgeReady()) return { installed: true };
|
|
202
202
|
|
|
203
203
|
const [ok, err] = pcall(() => {
|
|
204
|
+
destroyIfPresent(ServerScriptService, SERVER_SCRIPT_NAME);
|
|
205
|
+
destroyIfPresent(ServerScriptService, BRIDGE_NAMES.serverLocal);
|
|
206
|
+
|
|
204
207
|
const serverScript = new Instance("Script");
|
|
205
208
|
serverScript.Name = SERVER_SCRIPT_NAME;
|
|
206
|
-
|
|
207
|
-
// script. cleanupBridges() removes it from the edit DM when the
|
|
208
|
-
// playtest ends.
|
|
209
|
+
serverScript.Archivable = false;
|
|
209
210
|
setSource(serverScript, SERVER_BRIDGE_SOURCE);
|
|
210
211
|
serverScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
|
|
211
212
|
serverScript.Parent = ServerScriptService;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!ok) {
|
|
216
|
+
return { installed: false, error: tostring(err) };
|
|
217
|
+
}
|
|
218
|
+
return { installed: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function installClientRuntimeBridge(): InstallResult {
|
|
222
|
+
if (clientRuntimeBridgeReady()) return { installed: true };
|
|
223
|
+
|
|
224
|
+
const playerScripts = getPlayerScripts();
|
|
225
|
+
if (!playerScripts) {
|
|
226
|
+
return { installed: false, error: "Players.LocalPlayer.PlayerScripts not found - cannot install client eval bridge" };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const [ok, err] = pcall(() => {
|
|
230
|
+
destroyIfPresent(playerScripts, CLIENT_SCRIPT_NAME);
|
|
231
|
+
destroyIfPresent(ReplicatedStorage, BRIDGE_NAMES.clientLocal);
|
|
212
232
|
|
|
213
|
-
const sps = getStarterPlayerScripts();
|
|
214
|
-
if (!sps) {
|
|
215
|
-
error("StarterPlayer.StarterPlayerScripts not found - cannot install client eval bridge");
|
|
216
|
-
}
|
|
217
233
|
const clientScript = new Instance("LocalScript");
|
|
218
234
|
clientScript.Name = CLIENT_SCRIPT_NAME;
|
|
235
|
+
clientScript.Archivable = false;
|
|
219
236
|
setSource(clientScript, CLIENT_BRIDGE_SOURCE);
|
|
220
237
|
clientScript.SetAttribute(STAMP_ATTR, BRIDGE_STAMP);
|
|
221
|
-
clientScript.Parent =
|
|
238
|
+
clientScript.Parent = playerScripts;
|
|
222
239
|
});
|
|
223
240
|
|
|
224
241
|
if (!ok) {
|
|
@@ -226,3 +243,13 @@ export function installBridges(): { installed: boolean; error?: string } {
|
|
|
226
243
|
}
|
|
227
244
|
return { installed: true };
|
|
228
245
|
}
|
|
246
|
+
|
|
247
|
+
export function ensureRuntimeBridgeInstalled(): InstallResult {
|
|
248
|
+
if (!RunService.IsRunning()) {
|
|
249
|
+
return { installed: false, error: "Eval bridges are installed only in running play DataModels" };
|
|
250
|
+
}
|
|
251
|
+
if (RunService.IsServer()) {
|
|
252
|
+
return installServerRuntimeBridge();
|
|
253
|
+
}
|
|
254
|
+
return installClientRuntimeBridge();
|
|
255
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { HttpService } from "@rbxts/services";
|
|
2
|
+
|
|
3
|
+
interface FailureBody {
|
|
4
|
+
error?: string;
|
|
5
|
+
message?: string;
|
|
6
|
+
missingFields?: unknown;
|
|
7
|
+
request?: unknown;
|
|
8
|
+
existing?: unknown;
|
|
9
|
+
details?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodeForLog(value: unknown): string {
|
|
13
|
+
const [ok, encoded] = pcall(() => HttpService.JSONEncode(value));
|
|
14
|
+
return ok ? encoded : tostring(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatBody(body: string): string {
|
|
18
|
+
if (body === "") return "";
|
|
19
|
+
const [ok, decoded] = pcall(() => HttpService.JSONDecode(body));
|
|
20
|
+
if (ok && typeIs(decoded, "table")) {
|
|
21
|
+
const data = decoded as FailureBody;
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
if (typeIs(data.error, "string") && data.error !== "") parts.push(`error=${data.error}`);
|
|
24
|
+
if (typeIs(data.message, "string") && data.message !== "") parts.push(`message=${data.message}`);
|
|
25
|
+
if (data.missingFields !== undefined) parts.push(`missingFields=${encodeForLog(data.missingFields)}`);
|
|
26
|
+
if (data.request !== undefined) parts.push(`request=${encodeForLog(data.request)}`);
|
|
27
|
+
if (data.existing !== undefined) parts.push(`existing=${encodeForLog(data.existing)}`);
|
|
28
|
+
if (data.details !== undefined) parts.push(`details=${encodeForLog(data.details)}`);
|
|
29
|
+
if (parts.size() > 0) return parts.join(" ");
|
|
30
|
+
}
|
|
31
|
+
return `body=${body}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatRequestFailure(url: string, ok: boolean, res: unknown): string {
|
|
35
|
+
if (!ok) {
|
|
36
|
+
return `RequestAsync threw for ${url}: ${tostring(res)}`;
|
|
37
|
+
}
|
|
38
|
+
if (res === undefined) {
|
|
39
|
+
return `RequestAsync returned no response for ${url}`;
|
|
40
|
+
}
|
|
41
|
+
const response = res as RequestAsyncResponse;
|
|
42
|
+
const statusMessage = response.StatusMessage !== "" ? ` ${response.StatusMessage}` : "";
|
|
43
|
+
const body = formatBody(response.Body);
|
|
44
|
+
const suffix = body !== "" ? `: ${body}` : "";
|
|
45
|
+
return `HTTP ${response.StatusCode}${statusMessage} from ${url}${suffix}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export = {
|
|
49
|
+
formatRequestFailure,
|
|
50
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { HttpService, ServerStorage } from "@rbxts/services";
|
|
2
|
+
|
|
3
|
+
const SETTING_KEY_PREFIX = "MCP_SERVER_URL_";
|
|
4
|
+
|
|
5
|
+
let pluginRef: Plugin | undefined;
|
|
6
|
+
|
|
7
|
+
function init(p: Plugin): void {
|
|
8
|
+
pluginRef = p;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function computeInstanceId(): string {
|
|
12
|
+
if (game.PlaceId !== 0) {
|
|
13
|
+
return `place:${tostring(game.PlaceId)}`;
|
|
14
|
+
}
|
|
15
|
+
const existing = ServerStorage.GetAttribute("__MCPPlaceId");
|
|
16
|
+
if (typeIs(existing, "string") && existing !== "") {
|
|
17
|
+
return `anon:${existing as string}`;
|
|
18
|
+
}
|
|
19
|
+
const fresh = HttpService.GenerateGUID(false);
|
|
20
|
+
pcall(() => ServerStorage.SetAttribute("__MCPPlaceId", fresh));
|
|
21
|
+
return `anon:${fresh}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function settingKey(instanceId: string): string {
|
|
25
|
+
return SETTING_KEY_PREFIX + instanceId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rememberServerUrl(serverUrl: string): void {
|
|
29
|
+
if (!pluginRef || serverUrl === "") return;
|
|
30
|
+
const key = settingKey(computeInstanceId());
|
|
31
|
+
pcall(() => pluginRef!.SetSetting(key, serverUrl));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readServerUrl(): string | undefined {
|
|
35
|
+
if (!pluginRef) return undefined;
|
|
36
|
+
const key = settingKey(computeInstanceId());
|
|
37
|
+
const [ok, value] = pcall(() => pluginRef!.GetSetting(key));
|
|
38
|
+
if (ok && typeIs(value, "string") && value !== "") {
|
|
39
|
+
return value as string;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export = {
|
|
45
|
+
init,
|
|
46
|
+
rememberServerUrl,
|
|
47
|
+
readServerUrl,
|
|
48
|
+
};
|