@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.22.1",
3
+ "version": "2.22.3",
4
4
  "description": "Read-only MCP server for inspecting and debugging Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -843,19 +843,33 @@ local function processRequest(request)
843
843
  end
844
844
  end
845
845
  local function sendResponse(conn, requestId, responseData)
846
- pcall(function()
847
- HttpService:RequestAsync({
848
- Url = `{conn.serverUrl}/response`,
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 = HttpService:JSONEncode({
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.1" is replaced with the package version at package time
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.1`
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 bytes = #msg
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 = msg,
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.1"
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
- pcall(function()
847
- HttpService:RequestAsync({
848
- Url = `{conn.serverUrl}/response`,
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 = HttpService:JSONEncode({
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.1" is replaced with the package version at package time
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.1`
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 bytes = #msg
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 = msg,
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.1"
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
- pcall(() => {
201
- HttpService.RequestAsync({
202
- Url: `${conn.serverUrl}/response`,
203
- Method: "POST",
204
- Headers: { "Content-Type": "application/json" },
205
- Body: HttpService.JSONEncode({ requestId, response: responseData }),
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(msg: string, t: Enum.MessageType, ts = nowSec()): void {
61
- const bytes = msg.size();
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: msg,
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