@chrrxs/robloxstudio-mcp-inspector 2.22.1 → 2.22.3
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
CHANGED
|
@@ -889,6 +889,7 @@ function requiredClosedLineRange(body, toolName) {
|
|
|
889
889
|
}
|
|
890
890
|
function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
891
891
|
const app = express();
|
|
892
|
+
const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
|
|
892
893
|
let mcpServerActive = false;
|
|
893
894
|
let lastMCPActivity = 0;
|
|
894
895
|
let mcpServerStartTime = 0;
|
|
@@ -968,8 +969,15 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
968
969
|
res.json({
|
|
969
970
|
status: "ok",
|
|
970
971
|
service: "robloxstudio-mcp",
|
|
972
|
+
serverName: serverConfig?.name ?? "robloxstudio-mcp",
|
|
971
973
|
version: serverConfig?.version,
|
|
972
974
|
serverVersion: serverConfig?.version,
|
|
975
|
+
capabilities: studioLifecycleCallable ? {
|
|
976
|
+
studioLifecycle: {
|
|
977
|
+
protocolVersion: 1,
|
|
978
|
+
endpoint: "/mcp/manage_instance"
|
|
979
|
+
}
|
|
980
|
+
} : {},
|
|
973
981
|
pluginConnected: instances.length > 0,
|
|
974
982
|
instanceCount: instances.length,
|
|
975
983
|
instances: publicInstances,
|
|
@@ -3426,6 +3434,65 @@ function toStudioLaunchArg(arg) {
|
|
|
3426
3434
|
function powershellStringLiteral(value) {
|
|
3427
3435
|
return `'${value.replace(/'/g, "''")}'`;
|
|
3428
3436
|
}
|
|
3437
|
+
function parseStudioProcessEnvironmentPatch(value) {
|
|
3438
|
+
if (value === void 0)
|
|
3439
|
+
return void 0;
|
|
3440
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
3441
|
+
throw new Error("process_environment must be an object when provided.");
|
|
3442
|
+
}
|
|
3443
|
+
const raw = value;
|
|
3444
|
+
const unsupported = Object.keys(raw).filter((key) => key !== "set" && key !== "remove");
|
|
3445
|
+
if (unsupported.length > 0) {
|
|
3446
|
+
throw new Error(`process_environment contains unsupported field(s): ${unsupported.join(", ")}.`);
|
|
3447
|
+
}
|
|
3448
|
+
let set;
|
|
3449
|
+
if (raw.set !== void 0) {
|
|
3450
|
+
if (raw.set === null || typeof raw.set !== "object" || Array.isArray(raw.set)) {
|
|
3451
|
+
throw new Error("process_environment.set must be an object mapping names to string values.");
|
|
3452
|
+
}
|
|
3453
|
+
set = {};
|
|
3454
|
+
for (const [name, setting] of Object.entries(raw.set)) {
|
|
3455
|
+
if (!ENVIRONMENT_VARIABLE_NAME.test(name)) {
|
|
3456
|
+
throw new Error(`Invalid process environment variable name "${name}".`);
|
|
3457
|
+
}
|
|
3458
|
+
if (typeof setting !== "string") {
|
|
3459
|
+
throw new Error(`process_environment.set.${name} must be a string.`);
|
|
3460
|
+
}
|
|
3461
|
+
if (setting.includes("\0")) {
|
|
3462
|
+
throw new Error(`process_environment.set.${name} must not contain a null character.`);
|
|
3463
|
+
}
|
|
3464
|
+
set[name] = setting;
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
let remove;
|
|
3468
|
+
if (raw.remove !== void 0) {
|
|
3469
|
+
if (!Array.isArray(raw.remove) || raw.remove.some((name) => typeof name !== "string")) {
|
|
3470
|
+
throw new Error("process_environment.remove must be an array of environment variable names.");
|
|
3471
|
+
}
|
|
3472
|
+
remove = [...new Set(raw.remove)];
|
|
3473
|
+
for (const name of remove) {
|
|
3474
|
+
if (!ENVIRONMENT_VARIABLE_NAME.test(name)) {
|
|
3475
|
+
throw new Error(`Invalid process environment variable name "${name}".`);
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
return { set, remove };
|
|
3480
|
+
}
|
|
3481
|
+
function patchedProcessEnvironment(patch) {
|
|
3482
|
+
const environment = { ...process.env };
|
|
3483
|
+
for (const name of patch.remove ?? [])
|
|
3484
|
+
delete environment[name];
|
|
3485
|
+
for (const [name, value] of Object.entries(patch.set ?? {}))
|
|
3486
|
+
environment[name] = value;
|
|
3487
|
+
return environment;
|
|
3488
|
+
}
|
|
3489
|
+
function powershellEnvironmentPatchStatements(patch) {
|
|
3490
|
+
const target = "[EnvironmentVariableTarget]::Process";
|
|
3491
|
+
return [
|
|
3492
|
+
...(patch.remove ?? []).map((name) => `[Environment]::SetEnvironmentVariable(${powershellStringLiteral(name)}, $null, ${target})`),
|
|
3493
|
+
...Object.entries(patch.set ?? {}).map(([name, value]) => `[Environment]::SetEnvironmentVariable(${powershellStringLiteral(name)}, ${powershellStringLiteral(value)}, ${target})`)
|
|
3494
|
+
];
|
|
3495
|
+
}
|
|
3429
3496
|
function quoteWindowsCommandLineArg(value) {
|
|
3430
3497
|
if (value.length > 0 && !/[\s"]/u.test(value))
|
|
3431
3498
|
return value;
|
|
@@ -3446,10 +3513,12 @@ function quoteWindowsCommandLineArg(value) {
|
|
|
3446
3513
|
}
|
|
3447
3514
|
return `${quoted}${"\\".repeat(backslashes * 2)}"`;
|
|
3448
3515
|
}
|
|
3449
|
-
function buildWindowsStudioStartScript(exe, args) {
|
|
3516
|
+
function buildWindowsStudioStartScript(exe, args, processEnvironment) {
|
|
3517
|
+
const environmentPatch = parseStudioProcessEnvironmentPatch(processEnvironment);
|
|
3450
3518
|
const windowsExe = toStudioLaunchArg(exe);
|
|
3451
3519
|
const commandLine = args.map(quoteWindowsCommandLineArg).join(" ");
|
|
3452
3520
|
return [
|
|
3521
|
+
...environmentPatch ? powershellEnvironmentPatchStatements(environmentPatch) : [],
|
|
3453
3522
|
"$psi = New-Object System.Diagnostics.ProcessStartInfo",
|
|
3454
3523
|
`$psi.FileName = ${powershellStringLiteral(windowsExe)}`,
|
|
3455
3524
|
`$psi.Arguments = ${powershellStringLiteral(commandLine)}`,
|
|
@@ -3463,8 +3532,8 @@ function buildWindowsStudioStartScript(exe, args) {
|
|
|
3463
3532
|
'if ($null -eq $studio) { throw "Roblox Studio process did not start." }'
|
|
3464
3533
|
].join("; ");
|
|
3465
3534
|
}
|
|
3466
|
-
function spawnWindowsStudioFromWsl(exe, args) {
|
|
3467
|
-
const script = buildWindowsStudioStartScript(exe, args);
|
|
3535
|
+
function spawnWindowsStudioFromWsl(exe, args, processEnvironment) {
|
|
3536
|
+
const script = buildWindowsStudioStartScript(exe, args, processEnvironment);
|
|
3468
3537
|
const output = powershell(`${script}; [PSCustomObject]@{ pid = $studio.Id; started = $studio.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } | ConvertTo-Json -Compress`);
|
|
3469
3538
|
const parsed = JSON.parse(output);
|
|
3470
3539
|
const nativePid = Number(parsed.pid);
|
|
@@ -3674,7 +3743,7 @@ function delay(ms) {
|
|
|
3674
3743
|
function basenameAny(filePath) {
|
|
3675
3744
|
return path2.basename(filePath.replace(/\\/g, "/"));
|
|
3676
3745
|
}
|
|
3677
|
-
var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, STALE_BASEPLATE_MAX_AGE_MS, BASEPLATE_TEMP_SWEEP_NAME, StudioInstanceManager;
|
|
3746
|
+
var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, ENVIRONMENT_VARIABLE_NAME, STALE_BASEPLATE_MAX_AGE_MS, BASEPLATE_TEMP_SWEEP_NAME, StudioInstanceManager;
|
|
3678
3747
|
var init_studio_instance_manager = __esm({
|
|
3679
3748
|
"../core/dist/studio-instance-manager.js"() {
|
|
3680
3749
|
"use strict";
|
|
@@ -3682,6 +3751,7 @@ var init_studio_instance_manager = __esm({
|
|
|
3682
3751
|
BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
|
|
3683
3752
|
BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
|
|
3684
3753
|
BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
|
|
3754
|
+
ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
3685
3755
|
STALE_BASEPLATE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
3686
3756
|
BASEPLATE_TEMP_SWEEP_NAME = /^Baseplate-(\d+)-\d+\.rbxl(\.lock)?$/;
|
|
3687
3757
|
StudioInstanceManager = class {
|
|
@@ -3782,22 +3852,27 @@ var init_studio_instance_manager = __esm({
|
|
|
3782
3852
|
}
|
|
3783
3853
|
async launch(options) {
|
|
3784
3854
|
this.sweepRegistry();
|
|
3855
|
+
const processEnvironment = parseStudioProcessEnvironmentPatch(options.processEnvironment);
|
|
3856
|
+
if (options.studioExecutable !== void 0 && (typeof options.studioExecutable !== "string" || options.studioExecutable.length === 0 || options.studioExecutable.includes("\0"))) {
|
|
3857
|
+
throw new Error("studio_executable must be a non-empty string without null characters.");
|
|
3858
|
+
}
|
|
3785
3859
|
const preparedOptions = prepareStudioLaunchOptions(options);
|
|
3786
3860
|
const bootId = this.getCurrentBootId();
|
|
3787
3861
|
const before = new Set(this.listStudioProcesses().map((proc2) => proc2.Id));
|
|
3788
|
-
const exe = this.processAdapter.resolveStudioExe?.() ?? resolveStudioExe();
|
|
3862
|
+
const exe = preparedOptions.studioExecutable ?? this.processAdapter.resolveStudioExe?.() ?? resolveStudioExe();
|
|
3789
3863
|
const args = buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArg);
|
|
3790
3864
|
const spawnOptions = {
|
|
3791
3865
|
cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
|
|
3792
3866
|
detached: true,
|
|
3793
|
-
stdio: "ignore"
|
|
3867
|
+
stdio: "ignore",
|
|
3868
|
+
...processEnvironment ? { env: patchedProcessEnvironment(processEnvironment) } : {}
|
|
3794
3869
|
};
|
|
3795
3870
|
let proc;
|
|
3796
3871
|
try {
|
|
3797
3872
|
if (this.processAdapter.spawnStudio) {
|
|
3798
3873
|
proc = this.processAdapter.spawnStudio(exe, args, spawnOptions);
|
|
3799
3874
|
} else if (isWsl()) {
|
|
3800
|
-
proc = spawnWindowsStudioFromWsl(exe, args);
|
|
3875
|
+
proc = spawnWindowsStudioFromWsl(exe, args, processEnvironment);
|
|
3801
3876
|
} else {
|
|
3802
3877
|
const child = spawn(exe, args, spawnOptions);
|
|
3803
3878
|
proc = {
|
|
@@ -8730,6 +8805,7 @@ ${code}`
|
|
|
8730
8805
|
}
|
|
8731
8806
|
return this._textResult({
|
|
8732
8807
|
...this._managedStatus(record2),
|
|
8808
|
+
close_status: closeResult2.status,
|
|
8733
8809
|
message: closeResult2.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
|
|
8734
8810
|
});
|
|
8735
8811
|
}
|
|
@@ -8743,6 +8819,7 @@ ${code}`
|
|
|
8743
8819
|
const closedRecord = recordBeforeClose ?? (managedClose.launchId ? this.instanceManager.getByLaunchId(managedClose.launchId) : void 0);
|
|
8744
8820
|
return this._textResult({
|
|
8745
8821
|
...closedRecord ? this._managedStatus(closedRecord) : { instance_id },
|
|
8822
|
+
close_status: managedClose.status,
|
|
8746
8823
|
message: managedClose.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
|
|
8747
8824
|
});
|
|
8748
8825
|
}
|
|
@@ -8766,6 +8843,7 @@ ${code}`
|
|
|
8766
8843
|
await this.bridge.unregisterInstanceIdEverywhere(instance_id);
|
|
8767
8844
|
return this._textResult({
|
|
8768
8845
|
instance_id,
|
|
8846
|
+
close_status: "closed",
|
|
8769
8847
|
message: "Studio instance closed."
|
|
8770
8848
|
});
|
|
8771
8849
|
} else {
|
|
@@ -8790,6 +8868,7 @@ ${code}`
|
|
|
8790
8868
|
}
|
|
8791
8869
|
return this._textResult({
|
|
8792
8870
|
...this._managedStatus(record2),
|
|
8871
|
+
close_status: closeResult.status,
|
|
8793
8872
|
message: closeResult.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
|
|
8794
8873
|
});
|
|
8795
8874
|
}
|
|
@@ -8801,6 +8880,14 @@ ${code}`
|
|
|
8801
8880
|
const placeId = launchSource === "published_place" || launchSource === "place_revision" ? this._positiveInteger(request.place_id, "place_id") : void 0;
|
|
8802
8881
|
const placeVersion = launchSource === "place_revision" ? this._positiveInteger(request.place_version, "place_version") : void 0;
|
|
8803
8882
|
const localPlaceFile = typeof request.local_place_file === "string" ? request.local_place_file : void 0;
|
|
8883
|
+
let studioExecutable;
|
|
8884
|
+
if (request.studio_executable !== void 0) {
|
|
8885
|
+
if (typeof request.studio_executable !== "string" || request.studio_executable.length === 0) {
|
|
8886
|
+
throw new Error("studio_executable must be a non-empty string when provided.");
|
|
8887
|
+
}
|
|
8888
|
+
studioExecutable = request.studio_executable;
|
|
8889
|
+
}
|
|
8890
|
+
const processEnvironment = parseStudioProcessEnvironmentPatch(request.process_environment);
|
|
8804
8891
|
if (launchSource === "published_place" && placeId !== void 0 && this._isLatestPublishedPlaceOpen(placeId)) {
|
|
8805
8892
|
return this._textResult({
|
|
8806
8893
|
error: "Place is already open.",
|
|
@@ -8817,7 +8904,9 @@ ${code}`
|
|
|
8817
8904
|
placeId,
|
|
8818
8905
|
universeId,
|
|
8819
8906
|
placeVersion,
|
|
8820
|
-
connectionTimeoutMs: timeoutMs
|
|
8907
|
+
connectionTimeoutMs: timeoutMs,
|
|
8908
|
+
studioExecutable,
|
|
8909
|
+
processEnvironment
|
|
8821
8910
|
});
|
|
8822
8911
|
if (!waitForConnection) {
|
|
8823
8912
|
return this._textResult({
|
|
@@ -11874,6 +11963,35 @@ var init_definitions = __esm({
|
|
|
11874
11963
|
type: "number",
|
|
11875
11964
|
description: 'For action="launch": max milliseconds for plugin connection (default 120000). The deadline also applies asynchronously when wait_for_connection=false.'
|
|
11876
11965
|
},
|
|
11966
|
+
studio_executable: {
|
|
11967
|
+
type: "string",
|
|
11968
|
+
description: 'For action="launch": exact Roblox Studio executable to launch instead of auto-discovering a version.'
|
|
11969
|
+
},
|
|
11970
|
+
process_environment: {
|
|
11971
|
+
type: "object",
|
|
11972
|
+
description: 'For action="launch": process-scoped environment patch applied only while creating Studio. Values are never retained in the managed-instance registry.',
|
|
11973
|
+
properties: {
|
|
11974
|
+
set: {
|
|
11975
|
+
type: "object",
|
|
11976
|
+
description: "Environment variables to set for the Studio process.",
|
|
11977
|
+
propertyNames: {
|
|
11978
|
+
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
11979
|
+
},
|
|
11980
|
+
additionalProperties: {
|
|
11981
|
+
type: "string"
|
|
11982
|
+
}
|
|
11983
|
+
},
|
|
11984
|
+
remove: {
|
|
11985
|
+
type: "array",
|
|
11986
|
+
description: "Environment variables to remove from the Studio process environment.",
|
|
11987
|
+
items: {
|
|
11988
|
+
type: "string",
|
|
11989
|
+
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
11990
|
+
}
|
|
11991
|
+
}
|
|
11992
|
+
},
|
|
11993
|
+
additionalProperties: false
|
|
11994
|
+
},
|
|
11877
11995
|
max_page_size: {
|
|
11878
11996
|
type: "number",
|
|
11879
11997
|
description: 'For action="list_place_versions": number of versions to return, clamped to 1-50 (default 10).'
|
|
@@ -12243,7 +12361,7 @@ var init_definitions = __esm({
|
|
|
12243
12361
|
{
|
|
12244
12362
|
name: "get_runtime_logs",
|
|
12245
12363
|
category: "read",
|
|
12246
|
-
description: "Read the in-memory log buffers captured by Studio plugin peers. Each buffer captures ~64 KB of recent LogService output; runtime peers seed from LogService:GetLogHistory() at plugin load so early startup logs emitted before the plugin finishes loading can still be returned, then continue capturing LogService.MessageOut entries. Oldest entries drop when over budget. Entries include capturedBy for the plugin buffer that observed the log. In ordinary Studio play/run sessions, LogService reflects logs across edit/server/client, so script-origin peer is not reliable and entries omit peer. In StudioTestService multiplayer sessions only, peer attribution is reliable and entries also include peer. target=all (default) merges buffers and dedups same-message-and-level entries captured within 2s across different buffers.",
|
|
12364
|
+
description: "Read the in-memory log buffers captured by Studio plugin peers. Each buffer captures ~64 KB of recent LogService output; runtime peers seed from LogService:GetLogHistory() at plugin load so early startup logs emitted before the plugin finishes loading can still be returned, then continue capturing LogService.MessageOut entries. Live structured LogService entries include their context dictionary as optional data; Roblox GetLogHistory does not expose context for entries seeded at plugin load. Oldest entries drop when over budget. Entries include capturedBy for the plugin buffer that observed the log. In ordinary Studio play/run sessions, LogService reflects logs across edit/server/client, so script-origin peer is not reliable and entries omit peer. In StudioTestService multiplayer sessions only, peer attribution is reliable and entries also include peer. target=all (default) merges buffers and dedups same-message-and-level entries captured within 2s across different buffers.",
|
|
12247
12365
|
inputSchema: {
|
|
12248
12366
|
type: "object",
|
|
12249
12367
|
properties: {
|
package/package.json
CHANGED
|
@@ -843,19 +843,33 @@ local function processRequest(request)
|
|
|
843
843
|
end
|
|
844
844
|
end
|
|
845
845
|
local function sendResponse(conn, requestId, responseData)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
846
|
+
local responseUrl = `{conn.serverUrl}/response`
|
|
847
|
+
local encodeOk, encoded = pcall(function()
|
|
848
|
+
return HttpService:JSONEncode({
|
|
849
|
+
requestId = requestId,
|
|
850
|
+
response = responseData,
|
|
851
|
+
})
|
|
852
|
+
end)
|
|
853
|
+
local body = if encodeOk then encoded else HttpService:JSONEncode({
|
|
854
|
+
requestId = requestId,
|
|
855
|
+
error = `Plugin response serialization failed: {tostring(encoded)}`,
|
|
856
|
+
})
|
|
857
|
+
if not encodeOk then
|
|
858
|
+
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
859
|
+
end
|
|
860
|
+
local requestOk, requestResult = pcall(function()
|
|
861
|
+
return HttpService:RequestAsync({
|
|
862
|
+
Url = responseUrl,
|
|
849
863
|
Method = "POST",
|
|
850
864
|
Headers = {
|
|
851
865
|
["Content-Type"] = "application/json",
|
|
852
866
|
},
|
|
853
|
-
Body =
|
|
854
|
-
requestId = requestId,
|
|
855
|
-
response = responseData,
|
|
856
|
-
}),
|
|
867
|
+
Body = body,
|
|
857
868
|
})
|
|
858
869
|
end)
|
|
870
|
+
if not requestOk or not requestResult.Success then
|
|
871
|
+
warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
|
|
872
|
+
end
|
|
859
873
|
end
|
|
860
874
|
local function getConnectionStatus()
|
|
861
875
|
local conn = State.getActiveConnection()
|
|
@@ -1425,9 +1439,9 @@ local function computeBridgeStamp()
|
|
|
1425
1439
|
for i = 1, #combined do
|
|
1426
1440
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1427
1441
|
end
|
|
1428
|
-
-- "2.22.
|
|
1442
|
+
-- "2.22.3" is replaced with the package version at package time
|
|
1429
1443
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1430
|
-
return `{tostring(h)}-2.22.
|
|
1444
|
+
return `{tostring(h)}-2.22.3`
|
|
1431
1445
|
end
|
|
1432
1446
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1433
1447
|
local function setSource(scriptInst, source)
|
|
@@ -10437,6 +10451,41 @@ end
|
|
|
10437
10451
|
local function nowSec()
|
|
10438
10452
|
return DateTime.now().UnixTimestampMillis / 1000
|
|
10439
10453
|
end
|
|
10454
|
+
-- Studio occasionally exposes binary-bearing Output messages through
|
|
10455
|
+
-- LogService (for example, plugin hydration diagnostics containing raw CSG
|
|
10456
|
+
-- data). HttpService:JSONEncode rejects those strings outright. Preserve all
|
|
10457
|
+
-- valid UTF-8 verbatim and make only malformed bytes JSON-safe and visible.
|
|
10458
|
+
local function escapeInvalidUtf8(msg)
|
|
10459
|
+
local valid = utf8.len(msg)
|
|
10460
|
+
-- Roblox currently returns nil (not the false declared by @rbxts/types)
|
|
10461
|
+
-- when it encounters a malformed sequence. A numeric result is the only
|
|
10462
|
+
-- portable success discriminator across both representations.
|
|
10463
|
+
if type(valid) == "number" then
|
|
10464
|
+
return msg
|
|
10465
|
+
end
|
|
10466
|
+
local parts = {}
|
|
10467
|
+
local cursor = 1
|
|
10468
|
+
while cursor <= #msg do
|
|
10469
|
+
local suffixValid, invalidPosition = utf8.len(msg, cursor)
|
|
10470
|
+
if type(suffixValid) == "number" then
|
|
10471
|
+
local _arg0 = string.sub(msg, cursor)
|
|
10472
|
+
table.insert(parts, _arg0)
|
|
10473
|
+
break
|
|
10474
|
+
end
|
|
10475
|
+
if not (type(invalidPosition) == "number") then
|
|
10476
|
+
break
|
|
10477
|
+
end
|
|
10478
|
+
if invalidPosition > cursor then
|
|
10479
|
+
local _arg0 = string.sub(msg, cursor, invalidPosition - 1)
|
|
10480
|
+
table.insert(parts, _arg0)
|
|
10481
|
+
end
|
|
10482
|
+
local invalidByte = string.byte(msg, invalidPosition)
|
|
10483
|
+
local _arg0 = string.format("\\x%02X", invalidByte)
|
|
10484
|
+
table.insert(parts, _arg0)
|
|
10485
|
+
cursor = invalidPosition + 1
|
|
10486
|
+
end
|
|
10487
|
+
return table.concat(parts, "")
|
|
10488
|
+
end
|
|
10440
10489
|
local function dropOldestUntilFits(incomingBytes)
|
|
10441
10490
|
while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
|
|
10442
10491
|
local dropped = table.remove(entries, 1)
|
|
@@ -10444,17 +10493,19 @@ local function dropOldestUntilFits(incomingBytes)
|
|
|
10444
10493
|
totalDropped += 1
|
|
10445
10494
|
end
|
|
10446
10495
|
end
|
|
10447
|
-
local function pushEntry(msg, t, ts)
|
|
10496
|
+
local function pushEntry(msg, t, ts, data)
|
|
10448
10497
|
if ts == nil then
|
|
10449
10498
|
ts = nowSec()
|
|
10450
10499
|
end
|
|
10451
|
-
local
|
|
10500
|
+
local safeMessage = escapeInvalidUtf8(msg)
|
|
10501
|
+
local bytes = #safeMessage
|
|
10452
10502
|
dropOldestUntilFits(bytes)
|
|
10453
10503
|
local _arg0 = {
|
|
10454
10504
|
seq = nextSeq,
|
|
10455
10505
|
ts = ts,
|
|
10456
10506
|
level = levelTag(t),
|
|
10457
|
-
message =
|
|
10507
|
+
message = safeMessage,
|
|
10508
|
+
data = data,
|
|
10458
10509
|
}
|
|
10459
10510
|
table.insert(entries, _arg0)
|
|
10460
10511
|
nextSeq += 1
|
|
@@ -10499,8 +10550,8 @@ local function install()
|
|
|
10499
10550
|
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
|
|
10500
10551
|
-- edit history is bounded to the current Studio process above.
|
|
10501
10552
|
seedRuntimeHistory()
|
|
10502
|
-
LogService.MessageOut:Connect(function(msg, t)
|
|
10503
|
-
pushEntry(msg, t)
|
|
10553
|
+
LogService.MessageOut:Connect(function(msg, t, context)
|
|
10554
|
+
pushEntry(msg, t, nil, context)
|
|
10504
10555
|
end)
|
|
10505
10556
|
end
|
|
10506
10557
|
local function detectPeer()
|
|
@@ -10763,7 +10814,7 @@ return {
|
|
|
10763
10814
|
<Properties>
|
|
10764
10815
|
<string name="Name">State</string>
|
|
10765
10816
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10766
|
-
local CURRENT_VERSION = "2.22.
|
|
10817
|
+
local CURRENT_VERSION = "2.22.3"
|
|
10767
10818
|
local PLUGIN_VARIANT = "inspector"
|
|
10768
10819
|
local BASE_PORT = 58741
|
|
10769
10820
|
local function createConnection(port)
|
|
@@ -843,19 +843,33 @@ local function processRequest(request)
|
|
|
843
843
|
end
|
|
844
844
|
end
|
|
845
845
|
local function sendResponse(conn, requestId, responseData)
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
846
|
+
local responseUrl = `{conn.serverUrl}/response`
|
|
847
|
+
local encodeOk, encoded = pcall(function()
|
|
848
|
+
return HttpService:JSONEncode({
|
|
849
|
+
requestId = requestId,
|
|
850
|
+
response = responseData,
|
|
851
|
+
})
|
|
852
|
+
end)
|
|
853
|
+
local body = if encodeOk then encoded else HttpService:JSONEncode({
|
|
854
|
+
requestId = requestId,
|
|
855
|
+
error = `Plugin response serialization failed: {tostring(encoded)}`,
|
|
856
|
+
})
|
|
857
|
+
if not encodeOk then
|
|
858
|
+
warn(`[robloxstudio-mcp] Failed to serialize response {requestId}: {tostring(encoded)}`)
|
|
859
|
+
end
|
|
860
|
+
local requestOk, requestResult = pcall(function()
|
|
861
|
+
return HttpService:RequestAsync({
|
|
862
|
+
Url = responseUrl,
|
|
849
863
|
Method = "POST",
|
|
850
864
|
Headers = {
|
|
851
865
|
["Content-Type"] = "application/json",
|
|
852
866
|
},
|
|
853
|
-
Body =
|
|
854
|
-
requestId = requestId,
|
|
855
|
-
response = responseData,
|
|
856
|
-
}),
|
|
867
|
+
Body = body,
|
|
857
868
|
})
|
|
858
869
|
end)
|
|
870
|
+
if not requestOk or not requestResult.Success then
|
|
871
|
+
warn(`[robloxstudio-mcp] Failed to deliver response {requestId}: {HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`)
|
|
872
|
+
end
|
|
859
873
|
end
|
|
860
874
|
local function getConnectionStatus()
|
|
861
875
|
local conn = State.getActiveConnection()
|
|
@@ -1425,9 +1439,9 @@ local function computeBridgeStamp()
|
|
|
1425
1439
|
for i = 1, #combined do
|
|
1426
1440
|
h = (h * 33 + (string.byte(combined, i))) % 2147483647
|
|
1427
1441
|
end
|
|
1428
|
-
-- "2.22.
|
|
1442
|
+
-- "2.22.3" is replaced with the package version at package time
|
|
1429
1443
|
-- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
|
|
1430
|
-
return `{tostring(h)}-2.22.
|
|
1444
|
+
return `{tostring(h)}-2.22.3`
|
|
1431
1445
|
end
|
|
1432
1446
|
local BRIDGE_STAMP = computeBridgeStamp()
|
|
1433
1447
|
local function setSource(scriptInst, source)
|
|
@@ -10437,6 +10451,41 @@ end
|
|
|
10437
10451
|
local function nowSec()
|
|
10438
10452
|
return DateTime.now().UnixTimestampMillis / 1000
|
|
10439
10453
|
end
|
|
10454
|
+
-- Studio occasionally exposes binary-bearing Output messages through
|
|
10455
|
+
-- LogService (for example, plugin hydration diagnostics containing raw CSG
|
|
10456
|
+
-- data). HttpService:JSONEncode rejects those strings outright. Preserve all
|
|
10457
|
+
-- valid UTF-8 verbatim and make only malformed bytes JSON-safe and visible.
|
|
10458
|
+
local function escapeInvalidUtf8(msg)
|
|
10459
|
+
local valid = utf8.len(msg)
|
|
10460
|
+
-- Roblox currently returns nil (not the false declared by @rbxts/types)
|
|
10461
|
+
-- when it encounters a malformed sequence. A numeric result is the only
|
|
10462
|
+
-- portable success discriminator across both representations.
|
|
10463
|
+
if type(valid) == "number" then
|
|
10464
|
+
return msg
|
|
10465
|
+
end
|
|
10466
|
+
local parts = {}
|
|
10467
|
+
local cursor = 1
|
|
10468
|
+
while cursor <= #msg do
|
|
10469
|
+
local suffixValid, invalidPosition = utf8.len(msg, cursor)
|
|
10470
|
+
if type(suffixValid) == "number" then
|
|
10471
|
+
local _arg0 = string.sub(msg, cursor)
|
|
10472
|
+
table.insert(parts, _arg0)
|
|
10473
|
+
break
|
|
10474
|
+
end
|
|
10475
|
+
if not (type(invalidPosition) == "number") then
|
|
10476
|
+
break
|
|
10477
|
+
end
|
|
10478
|
+
if invalidPosition > cursor then
|
|
10479
|
+
local _arg0 = string.sub(msg, cursor, invalidPosition - 1)
|
|
10480
|
+
table.insert(parts, _arg0)
|
|
10481
|
+
end
|
|
10482
|
+
local invalidByte = string.byte(msg, invalidPosition)
|
|
10483
|
+
local _arg0 = string.format("\\x%02X", invalidByte)
|
|
10484
|
+
table.insert(parts, _arg0)
|
|
10485
|
+
cursor = invalidPosition + 1
|
|
10486
|
+
end
|
|
10487
|
+
return table.concat(parts, "")
|
|
10488
|
+
end
|
|
10440
10489
|
local function dropOldestUntilFits(incomingBytes)
|
|
10441
10490
|
while #entries > 0 and (totalBytes + incomingBytes > MAX_BYTES or #entries >= HARD_ENTRY_CAP) do
|
|
10442
10491
|
local dropped = table.remove(entries, 1)
|
|
@@ -10444,17 +10493,19 @@ local function dropOldestUntilFits(incomingBytes)
|
|
|
10444
10493
|
totalDropped += 1
|
|
10445
10494
|
end
|
|
10446
10495
|
end
|
|
10447
|
-
local function pushEntry(msg, t, ts)
|
|
10496
|
+
local function pushEntry(msg, t, ts, data)
|
|
10448
10497
|
if ts == nil then
|
|
10449
10498
|
ts = nowSec()
|
|
10450
10499
|
end
|
|
10451
|
-
local
|
|
10500
|
+
local safeMessage = escapeInvalidUtf8(msg)
|
|
10501
|
+
local bytes = #safeMessage
|
|
10452
10502
|
dropOldestUntilFits(bytes)
|
|
10453
10503
|
local _arg0 = {
|
|
10454
10504
|
seq = nextSeq,
|
|
10455
10505
|
ts = ts,
|
|
10456
10506
|
level = levelTag(t),
|
|
10457
|
-
message =
|
|
10507
|
+
message = safeMessage,
|
|
10508
|
+
data = data,
|
|
10458
10509
|
}
|
|
10459
10510
|
table.insert(entries, _arg0)
|
|
10460
10511
|
nextSeq += 1
|
|
@@ -10499,8 +10550,8 @@ local function install()
|
|
|
10499
10550
|
-- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
|
|
10500
10551
|
-- edit history is bounded to the current Studio process above.
|
|
10501
10552
|
seedRuntimeHistory()
|
|
10502
|
-
LogService.MessageOut:Connect(function(msg, t)
|
|
10503
|
-
pushEntry(msg, t)
|
|
10553
|
+
LogService.MessageOut:Connect(function(msg, t, context)
|
|
10554
|
+
pushEntry(msg, t, nil, context)
|
|
10504
10555
|
end)
|
|
10505
10556
|
end
|
|
10506
10557
|
local function detectPeer()
|
|
@@ -10763,7 +10814,7 @@ return {
|
|
|
10763
10814
|
<Properties>
|
|
10764
10815
|
<string name="Name">State</string>
|
|
10765
10816
|
<string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
|
|
10766
|
-
local CURRENT_VERSION = "2.22.
|
|
10817
|
+
local CURRENT_VERSION = "2.22.3"
|
|
10767
10818
|
local PLUGIN_VARIANT = "main"
|
|
10768
10819
|
local BASE_PORT = 58741
|
|
10769
10820
|
local function createConnection(port)
|
|
@@ -197,14 +197,27 @@ function processRequest(request: RequestPayload): unknown {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
function sendResponse(conn: Connection, requestId: string, responseData: unknown) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
const responseUrl = `${conn.serverUrl}/response`;
|
|
201
|
+
const [encodeOk, encoded] = pcall(() => HttpService.JSONEncode({ requestId, response: responseData }));
|
|
202
|
+
const body = encodeOk
|
|
203
|
+
? encoded
|
|
204
|
+
: HttpService.JSONEncode({
|
|
205
|
+
requestId,
|
|
206
|
+
error: `Plugin response serialization failed: ${tostring(encoded)}`,
|
|
206
207
|
});
|
|
207
|
-
|
|
208
|
+
if (!encodeOk) {
|
|
209
|
+
warn(`[robloxstudio-mcp] Failed to serialize response ${requestId}: ${tostring(encoded)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const [requestOk, requestResult] = pcall(() => HttpService.RequestAsync({
|
|
213
|
+
Url: responseUrl,
|
|
214
|
+
Method: "POST",
|
|
215
|
+
Headers: { "Content-Type": "application/json" },
|
|
216
|
+
Body: body,
|
|
217
|
+
}));
|
|
218
|
+
if (!requestOk || !requestResult.Success) {
|
|
219
|
+
warn(`[robloxstudio-mcp] Failed to deliver response ${requestId}: ${HttpDiagnostics.formatRequestFailure(responseUrl, requestOk, requestResult)}`);
|
|
220
|
+
}
|
|
208
221
|
}
|
|
209
222
|
|
|
210
223
|
function getConnectionStatus(): string {
|
|
@@ -24,6 +24,7 @@ interface RuntimeLogEntry {
|
|
|
24
24
|
ts: number; // wall-clock seconds via DateTime, coherent across peers
|
|
25
25
|
level: LogLevel;
|
|
26
26
|
message: string;
|
|
27
|
+
data?: Record<string, unknown>;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const MAX_BYTES = 64 * 1024;
|
|
@@ -46,6 +47,37 @@ function nowSec(): number {
|
|
|
46
47
|
return DateTime.now().UnixTimestampMillis / 1000;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// Studio occasionally exposes binary-bearing Output messages through
|
|
51
|
+
// LogService (for example, plugin hydration diagnostics containing raw CSG
|
|
52
|
+
// data). HttpService:JSONEncode rejects those strings outright. Preserve all
|
|
53
|
+
// valid UTF-8 verbatim and make only malformed bytes JSON-safe and visible.
|
|
54
|
+
function escapeInvalidUtf8(msg: string): string {
|
|
55
|
+
const [valid] = utf8.len(msg);
|
|
56
|
+
// Roblox currently returns nil (not the false declared by @rbxts/types)
|
|
57
|
+
// when it encounters a malformed sequence. A numeric result is the only
|
|
58
|
+
// portable success discriminator across both representations.
|
|
59
|
+
if (typeIs(valid, "number")) return msg;
|
|
60
|
+
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
let cursor = 1;
|
|
63
|
+
while (cursor <= msg.size()) {
|
|
64
|
+
const [suffixValid, invalidPosition] = utf8.len(msg, cursor);
|
|
65
|
+
if (typeIs(suffixValid, "number")) {
|
|
66
|
+
parts.push(string.sub(msg, cursor));
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (!typeIs(invalidPosition, "number")) break;
|
|
70
|
+
|
|
71
|
+
if (invalidPosition > cursor) {
|
|
72
|
+
parts.push(string.sub(msg, cursor, invalidPosition - 1));
|
|
73
|
+
}
|
|
74
|
+
const [invalidByte] = string.byte(msg, invalidPosition);
|
|
75
|
+
parts.push(string.format("\\x%02X", invalidByte));
|
|
76
|
+
cursor = invalidPosition + 1;
|
|
77
|
+
}
|
|
78
|
+
return parts.join("");
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
function dropOldestUntilFits(incomingBytes: number): void {
|
|
50
82
|
while (
|
|
51
83
|
entries.size() > 0 &&
|
|
@@ -57,14 +89,21 @@ function dropOldestUntilFits(incomingBytes: number): void {
|
|
|
57
89
|
}
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
function pushEntry(
|
|
61
|
-
|
|
92
|
+
function pushEntry(
|
|
93
|
+
msg: string,
|
|
94
|
+
t: Enum.MessageType,
|
|
95
|
+
ts = nowSec(),
|
|
96
|
+
data?: Record<string, unknown>,
|
|
97
|
+
): void {
|
|
98
|
+
const safeMessage = escapeInvalidUtf8(msg);
|
|
99
|
+
const bytes = safeMessage.size();
|
|
62
100
|
dropOldestUntilFits(bytes);
|
|
63
101
|
entries.push({
|
|
64
102
|
seq: nextSeq,
|
|
65
103
|
ts,
|
|
66
104
|
level: levelTag(t),
|
|
67
|
-
message:
|
|
105
|
+
message: safeMessage,
|
|
106
|
+
data,
|
|
68
107
|
});
|
|
69
108
|
nextSeq += 1;
|
|
70
109
|
totalBytes += bytes;
|
|
@@ -103,8 +142,8 @@ function install(): void {
|
|
|
103
142
|
// Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
|
|
104
143
|
// edit history is bounded to the current Studio process above.
|
|
105
144
|
seedRuntimeHistory();
|
|
106
|
-
LogService.MessageOut.Connect((msg, t) => {
|
|
107
|
-
pushEntry(msg, t);
|
|
145
|
+
LogService.MessageOut.Connect((msg, t, context?: Record<string, unknown>) => {
|
|
146
|
+
pushEntry(msg, t, undefined, context);
|
|
108
147
|
});
|
|
109
148
|
}
|
|
110
149
|
|