@chrrxs/robloxstudio-mcp-inspector 2.22.2 → 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.2",
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",
@@ -1439,9 +1439,9 @@ local function computeBridgeStamp()
1439
1439
  for i = 1, #combined do
1440
1440
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1441
1441
  end
1442
- -- "2.22.2" is replaced with the package version at package time
1442
+ -- "2.22.3" is replaced with the package version at package time
1443
1443
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1444
- return `{tostring(h)}-2.22.2`
1444
+ return `{tostring(h)}-2.22.3`
1445
1445
  end
1446
1446
  local BRIDGE_STAMP = computeBridgeStamp()
1447
1447
  local function setSource(scriptInst, source)
@@ -10493,7 +10493,7 @@ local function dropOldestUntilFits(incomingBytes)
10493
10493
  totalDropped += 1
10494
10494
  end
10495
10495
  end
10496
- local function pushEntry(msg, t, ts)
10496
+ local function pushEntry(msg, t, ts, data)
10497
10497
  if ts == nil then
10498
10498
  ts = nowSec()
10499
10499
  end
@@ -10505,6 +10505,7 @@ local function pushEntry(msg, t, ts)
10505
10505
  ts = ts,
10506
10506
  level = levelTag(t),
10507
10507
  message = safeMessage,
10508
+ data = data,
10508
10509
  }
10509
10510
  table.insert(entries, _arg0)
10510
10511
  nextSeq += 1
@@ -10549,8 +10550,8 @@ local function install()
10549
10550
  -- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
10550
10551
  -- edit history is bounded to the current Studio process above.
10551
10552
  seedRuntimeHistory()
10552
- LogService.MessageOut:Connect(function(msg, t)
10553
- pushEntry(msg, t)
10553
+ LogService.MessageOut:Connect(function(msg, t, context)
10554
+ pushEntry(msg, t, nil, context)
10554
10555
  end)
10555
10556
  end
10556
10557
  local function detectPeer()
@@ -10813,7 +10814,7 @@ return {
10813
10814
  <Properties>
10814
10815
  <string name="Name">State</string>
10815
10816
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10816
- local CURRENT_VERSION = "2.22.2"
10817
+ local CURRENT_VERSION = "2.22.3"
10817
10818
  local PLUGIN_VARIANT = "inspector"
10818
10819
  local BASE_PORT = 58741
10819
10820
  local function createConnection(port)
@@ -1439,9 +1439,9 @@ local function computeBridgeStamp()
1439
1439
  for i = 1, #combined do
1440
1440
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1441
1441
  end
1442
- -- "2.22.2" is replaced with the package version at package time
1442
+ -- "2.22.3" is replaced with the package version at package time
1443
1443
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1444
- return `{tostring(h)}-2.22.2`
1444
+ return `{tostring(h)}-2.22.3`
1445
1445
  end
1446
1446
  local BRIDGE_STAMP = computeBridgeStamp()
1447
1447
  local function setSource(scriptInst, source)
@@ -10493,7 +10493,7 @@ local function dropOldestUntilFits(incomingBytes)
10493
10493
  totalDropped += 1
10494
10494
  end
10495
10495
  end
10496
- local function pushEntry(msg, t, ts)
10496
+ local function pushEntry(msg, t, ts, data)
10497
10497
  if ts == nil then
10498
10498
  ts = nowSec()
10499
10499
  end
@@ -10505,6 +10505,7 @@ local function pushEntry(msg, t, ts)
10505
10505
  ts = ts,
10506
10506
  level = levelTag(t),
10507
10507
  message = safeMessage,
10508
+ data = data,
10508
10509
  }
10509
10510
  table.insert(entries, _arg0)
10510
10511
  nextSeq += 1
@@ -10549,8 +10550,8 @@ local function install()
10549
10550
  -- Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
10550
10551
  -- edit history is bounded to the current Studio process above.
10551
10552
  seedRuntimeHistory()
10552
- LogService.MessageOut:Connect(function(msg, t)
10553
- pushEntry(msg, t)
10553
+ LogService.MessageOut:Connect(function(msg, t, context)
10554
+ pushEntry(msg, t, nil, context)
10554
10555
  end)
10555
10556
  end
10556
10557
  local function detectPeer()
@@ -10813,7 +10814,7 @@ return {
10813
10814
  <Properties>
10814
10815
  <string name="Name">State</string>
10815
10816
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10816
- local CURRENT_VERSION = "2.22.2"
10817
+ local CURRENT_VERSION = "2.22.3"
10817
10818
  local PLUGIN_VARIANT = "main"
10818
10819
  local BASE_PORT = 58741
10819
10820
  local function createConnection(port)
@@ -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;
@@ -88,7 +89,12 @@ function dropOldestUntilFits(incomingBytes: number): void {
88
89
  }
89
90
  }
90
91
 
91
- function pushEntry(msg: string, t: Enum.MessageType, ts = nowSec()): void {
92
+ function pushEntry(
93
+ msg: string,
94
+ t: Enum.MessageType,
95
+ ts = nowSec(),
96
+ data?: Record<string, unknown>,
97
+ ): void {
92
98
  const safeMessage = escapeInvalidUtf8(msg);
93
99
  const bytes = safeMessage.size();
94
100
  dropOldestUntilFits(bytes);
@@ -97,6 +103,7 @@ function pushEntry(msg: string, t: Enum.MessageType, ts = nowSec()): void {
97
103
  ts,
98
104
  level: levelTag(t),
99
105
  message: safeMessage,
106
+ data,
100
107
  });
101
108
  nextSeq += 1;
102
109
  totalBytes += bytes;
@@ -135,8 +142,8 @@ function install(): void {
135
142
  // Seed from per-DataModel LogHistory so get_runtime_logs can still see them;
136
143
  // edit history is bounded to the current Studio process above.
137
144
  seedRuntimeHistory();
138
- LogService.MessageOut.Connect((msg, t) => {
139
- pushEntry(msg, t);
145
+ LogService.MessageOut.Connect((msg, t, context?: Record<string, unknown>) => {
146
+ pushEntry(msg, t, undefined, context);
140
147
  });
141
148
  }
142
149