@chrrxs/robloxstudio-mcp-inspector 2.15.0 → 2.15.2

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
@@ -19,6 +19,10 @@ function toPublic(inst) {
19
19
  placeName: inst.placeName,
20
20
  dataModelName: inst.dataModelName,
21
21
  isRunning: inst.isRunning,
22
+ pluginVersion: inst.pluginVersion,
23
+ pluginVariant: inst.pluginVariant,
24
+ serverVersion: inst.serverVersion,
25
+ versionMismatch: inst.versionMismatch,
22
26
  lastActivity: inst.lastActivity,
23
27
  connectedAt: inst.connectedAt
24
28
  };
@@ -45,6 +49,10 @@ var init_bridge_service = __esm({
45
49
  const { pluginSessionId, instanceId, role } = input;
46
50
  const prior = this.instances.get(pluginSessionId);
47
51
  let assignedRole = role;
52
+ const pluginVersion = input.pluginVersion ?? "";
53
+ const pluginVariant = input.pluginVariant ?? "unknown";
54
+ const serverVersion = input.serverVersion ?? "";
55
+ const versionMismatch = pluginVersion !== "" && serverVersion !== "" && pluginVersion !== serverVersion;
48
56
  if (role === "client") {
49
57
  if (prior && prior.instanceId === instanceId && prior.role.match(/^client-\d+$/)) {
50
58
  assignedRole = prior.role;
@@ -82,6 +90,10 @@ var init_bridge_service = __esm({
82
90
  placeName: input.placeName ?? "",
83
91
  dataModelName: input.dataModelName ?? "",
84
92
  isRunning: input.isRunning ?? false,
93
+ pluginVersion,
94
+ pluginVariant,
95
+ serverVersion,
96
+ versionMismatch,
85
97
  lastActivity: Date.now(),
86
98
  connectedAt: prior?.connectedAt ?? Date.now()
87
99
  });
@@ -326,6 +338,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
326
338
  let lastMCPActivity = 0;
327
339
  let mcpServerStartTime = 0;
328
340
  const proxyInstances = /* @__PURE__ */ new Set();
341
+ const warnedVersionMismatches = /* @__PURE__ */ new Set();
329
342
  const setMCPServerActive = (active) => {
330
343
  mcpServerActive = active;
331
344
  if (active) {
@@ -354,13 +367,16 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
354
367
  app.use(express.urlencoded({ limit: "50mb", extended: true }));
355
368
  app.get("/health", (req, res) => {
356
369
  const instances = bridge.getInstances();
370
+ const publicInstances = instances.map(toPublic);
357
371
  res.json({
358
372
  status: "ok",
359
373
  service: "robloxstudio-mcp",
360
374
  version: serverConfig?.version,
375
+ serverVersion: serverConfig?.version,
361
376
  pluginConnected: instances.length > 0,
362
377
  instanceCount: instances.length,
363
- instances: instances.map(toPublic),
378
+ instances: publicInstances,
379
+ versionMismatch: publicInstances.some((inst) => inst.versionMismatch),
364
380
  mcpServerActive: isMCPServerActive(),
365
381
  uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0,
366
382
  pendingRequests: bridge.getPendingRequestCount(),
@@ -369,7 +385,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
369
385
  });
370
386
  });
371
387
  app.post("/ready", (req, res) => {
372
- const { pluginSessionId, instanceId, role, placeId, placeName, dataModelName, isRunning } = req.body;
388
+ const { pluginSessionId, instanceId, role, placeId, placeName, dataModelName, isRunning, pluginVersion, pluginVariant } = req.body;
373
389
  if (!pluginSessionId || !instanceId || !role) {
374
390
  res.status(400).json({
375
391
  success: false,
@@ -384,7 +400,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
384
400
  placeId: typeof placeId === "number" ? placeId : 0,
385
401
  placeName: typeof placeName === "string" ? placeName : "",
386
402
  dataModelName: typeof dataModelName === "string" ? dataModelName : "",
387
- isRunning: !!isRunning
403
+ isRunning: !!isRunning,
404
+ pluginVersion: typeof pluginVersion === "string" ? pluginVersion : "",
405
+ pluginVariant: typeof pluginVariant === "string" ? pluginVariant : "unknown",
406
+ serverVersion: serverConfig?.version ?? ""
388
407
  });
389
408
  if (!result.ok) {
390
409
  res.status(409).json({
@@ -395,10 +414,17 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
395
414
  });
396
415
  return;
397
416
  }
417
+ const registered = bridge.getInstanceBySessionId(pluginSessionId);
418
+ if (registered?.versionMismatch && !warnedVersionMismatches.has(pluginSessionId)) {
419
+ warnedVersionMismatches.add(pluginSessionId);
420
+ console.error(`[version-mismatch] Studio plugin v${registered.pluginVersion} (${registered.pluginVariant}) does not match MCP server v${registered.serverVersion} for ${registered.instanceId}/${registered.role}`);
421
+ }
398
422
  res.json({
399
423
  success: true,
400
424
  assignedRole: result.assignedRole,
401
- instanceId: result.instanceId
425
+ instanceId: result.instanceId,
426
+ serverVersion: serverConfig?.version,
427
+ versionMismatch: registered?.versionMismatch ?? false
402
428
  });
403
429
  });
404
430
  app.post("/disconnect", (req, res) => {
@@ -410,17 +436,25 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
410
436
  });
411
437
  app.get("/status", (req, res) => {
412
438
  const instances = bridge.getInstances();
439
+ const publicInstances = instances.map(toPublic);
413
440
  res.json({
414
441
  pluginConnected: instances.length > 0,
415
442
  instanceCount: instances.length,
416
- instances: instances.map(toPublic),
443
+ instances: publicInstances,
444
+ serverVersion: serverConfig?.version,
445
+ versionMismatch: publicInstances.some((inst) => inst.versionMismatch),
417
446
  mcpServerActive: isMCPServerActive(),
418
447
  lastMCPActivity,
419
448
  uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0
420
449
  });
421
450
  });
422
451
  app.get("/instances", (req, res) => {
423
- res.json({ instances: bridge.getInstances() });
452
+ const instances = bridge.getInstances();
453
+ res.json({
454
+ instances,
455
+ serverVersion: serverConfig?.version,
456
+ versionMismatch: instances.some((inst) => inst.versionMismatch)
457
+ });
424
458
  });
425
459
  app.get("/poll", (req, res) => {
426
460
  const pluginSessionId = req.query.pluginSessionId;
@@ -430,11 +464,17 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
430
464
  let callerInstanceId;
431
465
  let callerRole;
432
466
  let knownInstance = false;
467
+ let callerPluginVersion;
468
+ let callerPluginVariant;
469
+ let versionMismatch = false;
433
470
  if (pluginSessionId) {
434
471
  const inst = bridge.getInstanceBySessionId(pluginSessionId);
435
472
  if (inst) {
436
473
  callerInstanceId = inst.instanceId;
437
474
  callerRole = inst.role;
475
+ callerPluginVersion = inst.pluginVersion;
476
+ callerPluginVariant = inst.pluginVariant;
477
+ versionMismatch = inst.versionMismatch;
438
478
  knownInstance = true;
439
479
  }
440
480
  }
@@ -444,6 +484,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
444
484
  pluginConnected: true,
445
485
  mcpConnected: false,
446
486
  knownInstance,
487
+ serverVersion: serverConfig?.version,
488
+ pluginVersion: callerPluginVersion,
489
+ pluginVariant: callerPluginVariant,
490
+ versionMismatch,
447
491
  request: null
448
492
  });
449
493
  return;
@@ -456,6 +500,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
456
500
  mcpConnected: true,
457
501
  pluginConnected: true,
458
502
  knownInstance,
503
+ serverVersion: serverConfig?.version,
504
+ pluginVersion: callerPluginVersion,
505
+ pluginVariant: callerPluginVariant,
506
+ versionMismatch,
459
507
  proxyInstanceCount: proxyInstances.size
460
508
  });
461
509
  } else {
@@ -464,6 +512,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
464
512
  mcpConnected: true,
465
513
  pluginConnected: true,
466
514
  knownInstance,
515
+ serverVersion: serverConfig?.version,
516
+ pluginVersion: callerPluginVersion,
517
+ pluginVariant: callerPluginVariant,
518
+ versionMismatch,
467
519
  proxyInstanceCount: proxyInstances.size
468
520
  });
469
521
  }
@@ -2518,223 +2570,10 @@ function encodeImageFromRgbaResponse(response, format, quality) {
2518
2570
  mimeType: "image/jpeg"
2519
2571
  };
2520
2572
  }
2521
- function luaLongQuote(s) {
2522
- let level = 0;
2523
- while (s.includes(`]${"=".repeat(level)}]`))
2524
- level++;
2525
- const eq = "=".repeat(level);
2526
- return `[${eq}[
2527
- ${s}
2528
- ]${eq}]`;
2529
- }
2530
- function evalCountLines(s) {
2531
- return s.split("\n").length;
2532
- }
2533
- function buildModuleScriptInvokeWrapper(opts) {
2534
- const userLines = evalCountLines(opts.userCode);
2535
- const wrapped = `return ((function()
2536
- local __mcp_traceback
2537
- local __mcp_remap
2538
- local __mcp_LINE_OFFSET = ${EVAL_WRAPPER_LINE_OFFSET}
2539
- local __mcp_USER_LINES = ${userLines}
2540
- local __mcp_output = {}
2541
- local __mcp_real_print = print
2542
- local __mcp_real_warn = warn
2543
- local print = function(...)
2544
- __mcp_real_print(...)
2545
- local args = {...}
2546
- local parts = table.create(#args)
2547
- for i, a in ipairs(args) do parts[i] = tostring(a) end
2548
- table.insert(__mcp_output, table.concat(parts, "\\t"))
2549
- end
2550
- local warn = function(...)
2551
- __mcp_real_warn(...)
2552
- local args = {...}
2553
- local parts = table.create(#args)
2554
- for i, a in ipairs(args) do parts[i] = tostring(a) end
2555
- table.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))
2556
- end
2557
- local function __mcp_run()
2558
- ${opts.userCode}
2559
- end
2560
- __mcp_remap = function(s)
2561
- -- Two chunk-name formats can reference our payload: the
2562
- -- ModuleScript path "Workspace.__MCPEvalPayload:N" and the
2563
- -- loadstring chunk "[string \\"return ((function()...\\"]:N" (if
2564
- -- the IIFE happens to compile via loadstring). Normalize both to
2565
- -- "user_code:N" with the offset stripped AND clamped to user
2566
- -- range, otherwise unclosed constructs report nonsense lines deep
2567
- -- in the wrapper. Strip the "Workspace." parent prefix too so the
2568
- -- final output reads "user_code:N" not "Workspace.user_code:N".
2569
- local function __mcp_user_line(payload_n)
2570
- local user_n = payload_n - __mcp_LINE_OFFSET
2571
- if user_n < 1 then return "1" end
2572
- if user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end
2573
- return tostring(user_n)
2574
- end
2575
- s = string.gsub(s, "Workspace%.__MCPEvalPayload:(%d+)", function(num)
2576
- local n = tonumber(num)
2577
- if n then return "user_code:" .. __mcp_user_line(n) end
2578
- return "user_code:" .. num
2579
- end)
2580
- s = string.gsub(s, "__MCPEvalPayload:(%d+)", function(num)
2581
- local n = tonumber(num)
2582
- if n then return "user_code:" .. __mcp_user_line(n) end
2583
- return "user_code:" .. num
2584
- end)
2585
- s = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
2586
- local n = tonumber(num)
2587
- if n then return "user_code:" .. __mcp_user_line(n) end
2588
- return "user_code:" .. num
2589
- end)
2590
- return s
2591
- end
2592
- __mcp_traceback = function(err)
2593
- local raw = debug.traceback(tostring(err), 2)
2594
- local kept = {}
2595
- for line in string.gmatch(raw, "[^\\n]+") do
2596
- local num_str = string.match(line, "__MCPEvalPayload:(%d+)")
2597
- or string.match(line, '%[string "[^"]+"%]:(%d+)')
2598
- local n = num_str and tonumber(num_str)
2599
- -- Strip "in function '__mcp_run'" annotation BEFORE filtering:
2600
- -- user-code frames all carry that suffix (their source is
2601
- -- hosted inside __mcp_run), so a naive "__mcp_" filter would
2602
- -- drop every user frame and leave only the error header.
2603
- line = (string.gsub(line, " in function '__mcp_run'", ""))
2604
- local skip = string.find(line, "MCPPlugin", 1, true)
2605
- or string.find(line, "__mcp_", 1, true)
2606
- or string.find(line, "in function 'xpcall'", 1, true)
2607
- -- Drop wrapper preamble/postamble frames whose line falls
2608
- -- outside the user-code range \u2014 those are wrapper internals.
2609
- if n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then
2610
- skip = true
2611
- end
2612
- if not skip then
2613
- table.insert(kept, __mcp_remap(line))
2614
- end
2615
- end
2616
- return table.concat(kept, "\\n")
2617
- end
2618
- local ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)
2619
- return { ok = ok, value = errOrValue, output = __mcp_output }
2620
- end)())`;
2621
- return `
2622
- local HttpService = game:GetService("HttpService")
2623
- local bf = game:GetService("${opts.service}"):FindFirstChild("${opts.bridgeName}")
2624
- if not bf then
2625
- return HttpService:JSONEncode({
2626
- bridge = "missing",
2627
- error = ${luaLongQuote(opts.missingError)},
2628
- })
2629
- end
2630
- -- Outer-scope mirror of the in-IIFE __mcp_remap. Applied to parser errors
2631
- -- we pull out of LogService (those never pass through the IIFE) and to
2632
- -- the canned engine error string. Same offset as the IIFE's
2633
- -- __mcp_LINE_OFFSET; covers both chunk-name formats.
2634
- local __mcp_USER_LINES_OUTER = ${userLines}
2635
- local function __mcp_outer_user_line(payload_n)
2636
- local user_n = payload_n - ${EVAL_WRAPPER_LINE_OFFSET}
2637
- if user_n < 1 then return "1" end
2638
- if user_n > __mcp_USER_LINES_OUTER then return tostring(__mcp_USER_LINES_OUTER) .. " (at end of input)" end
2639
- return tostring(user_n)
2640
- end
2641
- local function __mcp_outer_remap(s)
2642
- s = string.gsub(s, "Workspace%.__MCPEvalPayload:(%d+)", function(num)
2643
- local n = tonumber(num)
2644
- if n then return "user_code:" .. __mcp_outer_user_line(n) end
2645
- return "user_code:" .. num
2646
- end)
2647
- s = string.gsub(s, "__MCPEvalPayload:(%d+)", function(num)
2648
- local n = tonumber(num)
2649
- if n then return "user_code:" .. __mcp_outer_user_line(n) end
2650
- return "user_code:" .. num
2651
- end)
2652
- s = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)
2653
- local n = tonumber(num)
2654
- if n then return "user_code:" .. __mcp_outer_user_line(n) end
2655
- return "user_code:" .. num
2656
- end)
2657
- return s
2658
- end
2659
- -- JSON-encode tables; otherwise tostring. Cycles or non-serializable
2660
- -- values fall back to tostring instead of erroring. This is what makes
2661
- -- eval_server_runtime / eval_client_runtime return structured table data
2662
- -- (matching execute_luau) instead of "table: 0xaddr".
2663
- local function __mcp_format(v)
2664
- if typeof(v) == "table" then
2665
- local ok, encoded = pcall(function() return HttpService:JSONEncode(v) end)
2666
- if ok then return encoded end
2667
- end
2668
- return tostring(v)
2669
- end
2670
- local USER_CODE = ${luaLongQuote(wrapped)}
2671
- local m = Instance.new("ModuleScript")
2672
- m.Name = "__MCPEvalPayload"
2673
- local okSet, setErr = pcall(function() m.Source = USER_CODE end)
2674
- if not okSet then
2675
- m:Destroy()
2676
- return HttpService:JSONEncode({ bridge = "ok", ok = false, error = "ModuleScript Source set failed: " .. tostring(setErr) })
2677
- end
2678
- m.Parent = workspace
2679
- local bridgeOk, inner = bf:Invoke(m)
2680
- m:Destroy()
2681
- if not bridgeOk then
2682
- local errMsg = tostring(inner)
2683
- -- pcall(require, payload) collapses parse/compile failures into the
2684
- -- canned engine string below. The real parser diagnostic was emitted
2685
- -- to LogService just before. Walk GetLogHistory backward for the most
2686
- -- recent ERR entry tagged at our payload path and substitute.
2687
- if errMsg == "Requested module experienced an error while loading" then
2688
- -- The parser diagnostic is emitted to LogService on the next
2689
- -- engine frame, not synchronously with pcall(require). task.wait(0)
2690
- -- yields too early; 50ms is enough to let the frame complete and
2691
- -- the message land in GetLogHistory.
2692
- task.wait(0.05)
2693
- local LogService = game:GetService("LogService")
2694
- local hist = LogService:GetLogHistory()
2695
- for i = #hist, 1, -1 do
2696
- local e = hist[i]
2697
- if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, 27) == "Workspace.__MCPEvalPayload:" then
2698
- errMsg = e.message
2699
- break
2700
- end
2701
- end
2702
- end
2703
- return HttpService:JSONEncode({ bridge = "ok", ok = false, error = __mcp_outer_remap(errMsg) })
2704
- end
2705
- -- inner is the {ok, value, output} table from our IIFE. Defensive: if it's
2706
- -- somehow not a table (caller bypassed the wrapper), fall back to old shape.
2707
- if typeof(inner) ~= "table" then
2708
- return HttpService:JSONEncode({
2709
- bridge = "ok",
2710
- ok = true,
2711
- result = if inner == nil then nil else __mcp_format(inner),
2712
- })
2713
- end
2714
- return HttpService:JSONEncode({
2715
- bridge = "ok",
2716
- ok = inner.ok == true,
2717
- result = if inner.ok and inner.value ~= nil then __mcp_format(inner.value) else nil,
2718
- error = if not inner.ok then tostring(inner.value) else nil,
2719
- output = inner.output or {},
2720
- })
2721
- `;
2722
- }
2723
- function parseBridgeResponse(response) {
2724
- const r = response;
2725
- if (r && typeof r.returnValue === "string") {
2726
- try {
2727
- const parsed = JSON.parse(r.returnValue);
2728
- return JSON.stringify(parsed);
2729
- } catch {
2730
- }
2731
- }
2732
- return JSON.stringify(response);
2733
- }
2734
2573
  function sleep(ms) {
2735
2574
  return new Promise((resolve2) => setTimeout(resolve2, ms));
2736
2575
  }
2737
- var SERVER_LOCAL_NAME, CLIENT_LOCAL_NAME, EVAL_WRAPPER_LINE_OFFSET, RobloxStudioTools;
2576
+ var RobloxStudioTools;
2738
2577
  var init_tools = __esm({
2739
2578
  "../core/dist/tools/index.js"() {
2740
2579
  "use strict";
@@ -2745,9 +2584,6 @@ var init_tools = __esm({
2745
2584
  init_roblox_cookie_client();
2746
2585
  init_jpeg_encoder();
2747
2586
  init_png_encoder();
2748
- SERVER_LOCAL_NAME = "__MCP_ServerEvalLocal";
2749
- CLIENT_LOCAL_NAME = "__MCP_ClientEvalBridge";
2750
- EVAL_WRAPPER_LINE_OFFSET = 23;
2751
2587
  RobloxStudioTools = class _RobloxStudioTools {
2752
2588
  client;
2753
2589
  bridge;
@@ -3424,18 +3260,12 @@ ${code}`
3424
3260
  if (!code) {
3425
3261
  throw new Error("Code is required for eval_server_runtime");
3426
3262
  }
3427
- const wrapper = buildModuleScriptInvokeWrapper({
3428
- service: "ServerScriptService",
3429
- bridgeName: SERVER_LOCAL_NAME,
3430
- missingError: "ServerEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
3431
- userCode: code
3432
- });
3433
- const response = await this._callSingle("/api/execute-luau", { code: wrapper }, "server", instance_id);
3263
+ const response = await this._callSingle("/api/eval-runtime", { code }, "server", instance_id);
3434
3264
  return {
3435
3265
  content: [
3436
3266
  {
3437
3267
  type: "text",
3438
- text: parseBridgeResponse(response)
3268
+ text: JSON.stringify(response)
3439
3269
  }
3440
3270
  ]
3441
3271
  };
@@ -3448,18 +3278,12 @@ ${code}`
3448
3278
  if (!clientTarget.startsWith("client-")) {
3449
3279
  throw new Error(`eval_client_runtime requires target=client-N (got: ${clientTarget})`);
3450
3280
  }
3451
- const wrapper = buildModuleScriptInvokeWrapper({
3452
- service: "ReplicatedStorage",
3453
- bridgeName: CLIENT_LOCAL_NAME,
3454
- missingError: "ClientEvalBridge not found. The bridge runs inside the play DM, so a playtest must be running. The bridge installs automatically (including for manually-started playtests); if a playtest is running and you still see this, reconnect the plugin in the edit window so the bridge reinstalls, then start the playtest again.",
3455
- userCode: code
3456
- });
3457
- const response = await this._callSingle("/api/execute-luau", { code: wrapper }, clientTarget, instance_id);
3281
+ const response = await this._callSingle("/api/eval-runtime", { code }, clientTarget, instance_id);
3458
3282
  return {
3459
3283
  content: [
3460
3284
  {
3461
3285
  type: "text",
3462
- text: parseBridgeResponse(response)
3286
+ text: JSON.stringify(response)
3463
3287
  }
3464
3288
  ]
3465
3289
  };
@@ -3477,11 +3301,19 @@ ${code}`
3477
3301
  if (!resolved.ok)
3478
3302
  throw new RoutingFailure(resolved.error);
3479
3303
  if (resolved.mode === "single") {
3304
+ const originPeerReliable2 = await this._isMultiplayerTestRunning(resolved.targetInstanceId);
3480
3305
  const response = await this.client.request("/api/get-runtime-logs", data, resolved.targetInstanceId, resolved.targetRole);
3481
- response.peer = resolved.targetRole;
3306
+ response.capturedBy = resolved.targetRole;
3307
+ delete response.peer;
3308
+ response.originPeerReliable = originPeerReliable2;
3309
+ response.peerAttribution = originPeerReliable2 ? "guaranteed_multiplayer" : "unavailable_shared_logservice";
3310
+ if (originPeerReliable2)
3311
+ response.peer = resolved.targetRole;
3482
3312
  if (Array.isArray(response.entries)) {
3483
3313
  for (const e of response.entries) {
3484
- if (e.peer !== void 0)
3314
+ e.capturedBy = resolved.targetRole;
3315
+ delete e.peer;
3316
+ if (originPeerReliable2)
3485
3317
  e.peer = resolved.targetRole;
3486
3318
  }
3487
3319
  }
@@ -3490,35 +3322,38 @@ ${code}`
3490
3322
  };
3491
3323
  }
3492
3324
  const targets = resolved.targets.filter((t) => t.targetRole !== "edit-proxy");
3325
+ const originPeerReliable = targets.length > 0 ? await this._isMultiplayerTestRunning(targets[0].targetInstanceId) : false;
3493
3326
  const responses = await Promise.allSettled(targets.map(async (t) => {
3494
3327
  const r = await this.client.request("/api/get-runtime-logs", data, t.targetInstanceId, t.targetRole);
3495
- return { ...r, peer: t.targetRole };
3328
+ return { ...r, capturedBy: t.targetRole };
3496
3329
  }));
3497
3330
  const merged = [];
3498
- const perPeerNextSince = {};
3499
- const perPeerErrors = {};
3331
+ const perCaptureNextSince = {};
3332
+ const perCaptureErrors = {};
3500
3333
  let totalDropped = 0;
3501
3334
  for (const r of responses) {
3502
3335
  if (r.status !== "fulfilled")
3503
3336
  continue;
3504
3337
  const v = r.value;
3505
- const peer = v.peer ?? "unknown";
3338
+ const capturedBy = v.capturedBy ?? "unknown";
3506
3339
  if (v.error) {
3507
- perPeerErrors[peer] = v.error;
3340
+ perCaptureErrors[capturedBy] = v.error;
3508
3341
  continue;
3509
3342
  }
3510
3343
  if (v.nextSince !== void 0)
3511
- perPeerNextSince[peer] = v.nextSince;
3344
+ perCaptureNextSince[capturedBy] = v.nextSince;
3512
3345
  totalDropped += v.totalDropped ?? 0;
3513
3346
  for (const e of v.entries ?? []) {
3514
- merged.push({ ...e, peer });
3347
+ const entry = { ...e };
3348
+ delete entry.peer;
3349
+ merged.push({ ...entry, capturedBy });
3515
3350
  }
3516
3351
  }
3517
3352
  merged.sort((a, b) => a.ts !== b.ts ? a.ts - b.ts : a.seq - b.seq);
3518
3353
  const DEDUP_WINDOW = 2;
3519
3354
  const deduped = [];
3520
3355
  for (const e of merged) {
3521
- const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.peer !== e.peer);
3356
+ const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.capturedBy !== e.capturedBy);
3522
3357
  if (!isDup)
3523
3358
  deduped.push(e);
3524
3359
  }
@@ -3526,13 +3361,21 @@ ${code}`
3526
3361
  if (tail !== void 0 && deduped.length > tail) {
3527
3362
  final = deduped.slice(deduped.length - tail);
3528
3363
  }
3364
+ const finalEntries = originPeerReliable ? final.map((e) => ({ ...e, peer: e.capturedBy })) : final;
3529
3365
  const body = {
3530
- entries: final,
3366
+ entries: finalEntries,
3531
3367
  totalDropped,
3532
- perPeerNextSince
3368
+ perCaptureNextSince,
3369
+ originPeerReliable,
3370
+ peerAttribution: originPeerReliable ? "guaranteed_multiplayer" : "unavailable_shared_logservice"
3533
3371
  };
3534
- if (Object.keys(perPeerErrors).length > 0) {
3535
- body.perPeerErrors = perPeerErrors;
3372
+ if (originPeerReliable) {
3373
+ body.perPeerNextSince = perCaptureNextSince;
3374
+ }
3375
+ if (Object.keys(perCaptureErrors).length > 0) {
3376
+ body.perCaptureErrors = perCaptureErrors;
3377
+ if (originPeerReliable)
3378
+ body.perPeerErrors = perCaptureErrors;
3536
3379
  }
3537
3380
  return {
3538
3381
  content: [{ type: "text", text: JSON.stringify(body) }]
@@ -6266,17 +6109,17 @@ var init_definitions = __esm({
6266
6109
  {
6267
6110
  name: "get_runtime_logs",
6268
6111
  category: "read",
6269
- description: "Read the in-memory log buffer captured by the plugin on each peer's LogService.MessageOut. Each peer (edit, server, client-N) captures ~64 KB of recent prints; oldest entries drop when over budget. Drop-oldest semantics preserve the recent tail, unlike get_console_output's 10 KB drop-newest cap. Caveat: peer tag reflects which peer's plugin captured the entry, not which peer's script originated it - LogService reflects prints across peers in Studio Play and origin is undetectable from inside MessageOut. target=all (default) merges all peers and dedups same-message-and-level entries captured within 2s across different peers.",
6112
+ description: "Read the in-memory log buffers captured by Studio plugin peers. Each buffer captures ~64 KB of recent 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.",
6270
6113
  inputSchema: {
6271
6114
  type: "object",
6272
6115
  properties: {
6273
6116
  target: {
6274
6117
  type: "string",
6275
- description: 'Peer to read from: "edit", "server", "client-N", or "all" (default). "all" merges all peers and dedups cross-peer reflections within a 2s window.'
6118
+ description: 'Capture buffer to read from: "edit", "server", "client-N", or "all" (default). "all" merges buffers and dedups cross-buffer reflections within a 2s window.'
6276
6119
  },
6277
6120
  since: {
6278
6121
  type: "number",
6279
- description: "Return only entries with seq > since. Pass back the previous response's nextSince (single-peer) or perPeerNextSince entry (target=all) for incremental polling."
6122
+ description: "Return only entries with seq > since. Pass back the previous response's nextSince (single target) or perCaptureNextSince entry (target=all) for incremental polling."
6280
6123
  },
6281
6124
  tail: {
6282
6125
  type: "number",
@@ -7283,23 +7126,23 @@ function getPluginsFolder() {
7283
7126
  }
7284
7127
  return join2(homedir2(), "Documents", "Roblox", "Plugins");
7285
7128
  }
7286
- function handleVariantConflict({ pluginsFolder, otherAssetName, replace }) {
7129
+ function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
7287
7130
  const otherDest = join2(pluginsFolder, otherAssetName);
7288
7131
  if (!existsSync2(otherDest))
7289
7132
  return;
7290
7133
  if (replace) {
7291
7134
  try {
7292
7135
  unlinkSync(otherDest);
7293
- console.log(`Removed conflicting ${otherAssetName}.`);
7136
+ log(`Removed conflicting ${otherAssetName}.`);
7294
7137
  } catch (err) {
7295
- console.warn(`[install-plugin] Could not remove ${otherDest}: ${err}. Continuing.`);
7138
+ warn(`[install-plugin] Could not remove ${otherDest}: ${err}. Continuing.`);
7296
7139
  }
7297
7140
  return;
7298
7141
  }
7299
- console.warn(`
7142
+ warn(`
7300
7143
  [install-plugin] WARNING: ${otherAssetName} is already present in ${pluginsFolder}.
7301
7144
  Only one MCP plugin variant should be present. If both variants are in the Studio Plugins folder, Studio loads both and runtime routing can become unpredictable.
7302
- Re-run with --replace-variant to remove ${otherAssetName}, or delete it manually.
7145
+ Delete ${otherAssetName} manually or use the default CLI installer behavior to replace it.
7303
7146
  `);
7304
7147
  }
7305
7148
  var init_install_plugin_helpers = __esm({
@@ -7327,10 +7170,12 @@ var init_dist = __esm({
7327
7170
  // src/install-plugin.ts
7328
7171
  var install_plugin_exports = {};
7329
7172
  __export(install_plugin_exports, {
7173
+ installBundledPlugin: () => installBundledPlugin,
7330
7174
  installPlugin: () => installPlugin
7331
7175
  });
7332
- import { createWriteStream, existsSync as existsSync3, mkdirSync as mkdirSync2, unlinkSync as unlinkSync2 } from "fs";
7333
- import { join as join3 } from "path";
7176
+ import { copyFileSync, createWriteStream, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, unlinkSync as unlinkSync2 } from "fs";
7177
+ import { dirname as dirname2, join as join3 } from "path";
7178
+ import { fileURLToPath } from "url";
7334
7179
  import { get } from "https";
7335
7180
  function httpsGet(url) {
7336
7181
  return new Promise((resolve2, reject) => {
@@ -7383,8 +7228,11 @@ async function fetchJson(url) {
7383
7228
  }
7384
7229
  return JSON.parse(Buffer.concat(chunks).toString());
7385
7230
  }
7386
- async function installPlugin() {
7387
- const replaceVariant = process.argv.includes("--replace-variant");
7231
+ function prepareInstall({
7232
+ replaceVariant,
7233
+ log,
7234
+ warn
7235
+ }) {
7388
7236
  const pluginsFolder = getPluginsFolder();
7389
7237
  if (!existsSync3(pluginsFolder)) {
7390
7238
  mkdirSync2(pluginsFolder, { recursive: true });
@@ -7392,18 +7240,89 @@ async function installPlugin() {
7392
7240
  handleVariantConflict({
7393
7241
  pluginsFolder,
7394
7242
  otherAssetName: OTHER_VARIANT,
7395
- replace: replaceVariant
7243
+ replace: replaceVariant,
7244
+ log,
7245
+ warn
7396
7246
  });
7397
- console.log("Fetching latest release...");
7247
+ return pluginsFolder;
7248
+ }
7249
+ function bundledAssetPath() {
7250
+ const currentDir = dirname2(fileURLToPath(import.meta.url));
7251
+ const candidates = [
7252
+ join3(currentDir, "..", "studio-plugin", ASSET_NAME),
7253
+ join3(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
7254
+ ];
7255
+ return candidates.find((candidate) => existsSync3(candidate)) ?? null;
7256
+ }
7257
+ function packageVersion() {
7258
+ const currentDir = dirname2(fileURLToPath(import.meta.url));
7259
+ const pkg = JSON.parse(readFileSync3(join3(currentDir, "..", "package.json"), "utf8"));
7260
+ if (!pkg.version) {
7261
+ throw new Error("Package version not found");
7262
+ }
7263
+ return pkg.version;
7264
+ }
7265
+ function bundledPluginVersion(source) {
7266
+ const match = readFileSync3(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
7267
+ return match ? match[1] : null;
7268
+ }
7269
+ function assertBundledPluginVersion(source) {
7270
+ const expected = packageVersion();
7271
+ const actual = bundledPluginVersion(source);
7272
+ if (actual !== expected) {
7273
+ throw new Error(
7274
+ `Bundled ${ASSET_NAME} version ${actual ?? "unknown"} does not match package version ${expected}. Run npm run build:plugin:inspector before starting with --auto-install-plugin.`
7275
+ );
7276
+ }
7277
+ }
7278
+ function filesMatch(a, b) {
7279
+ if (!existsSync3(b)) return false;
7280
+ const aBytes = readFileSync3(a);
7281
+ const bBytes = readFileSync3(b);
7282
+ return aBytes.length === bBytes.length && aBytes.equals(bBytes);
7283
+ }
7284
+ async function installBundledPlugin(options = {}) {
7285
+ const log = options.log ?? console.log;
7286
+ const warn = options.warn ?? console.warn;
7287
+ const replaceVariant = options.replaceVariant ?? true;
7288
+ const source = bundledAssetPath();
7289
+ if (!source) {
7290
+ throw new Error(`Bundled ${ASSET_NAME} not found in package`);
7291
+ }
7292
+ assertBundledPluginVersion(source);
7293
+ const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
7294
+ const dest = join3(pluginsFolder, ASSET_NAME);
7295
+ if (filesMatch(source, dest)) return;
7296
+ copyFileSync(source, dest);
7297
+ log(`Installed ${ASSET_NAME} to ${dest}`);
7298
+ }
7299
+ async function installPlugin(options = {}) {
7300
+ const replaceVariant = options.replaceVariant ?? true;
7301
+ const log = options.log ?? console.log;
7302
+ const warn = options.warn ?? console.warn;
7303
+ const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
7304
+ const bundled = bundledAssetPath();
7305
+ if (bundled) {
7306
+ assertBundledPluginVersion(bundled);
7307
+ const dest2 = join3(pluginsFolder, ASSET_NAME);
7308
+ if (filesMatch(bundled, dest2)) {
7309
+ log(`${ASSET_NAME} already installed.`);
7310
+ return;
7311
+ }
7312
+ copyFileSync(bundled, dest2);
7313
+ log(`Installed bundled ${ASSET_NAME} to ${dest2}`);
7314
+ return;
7315
+ }
7316
+ log("Fetching latest release...");
7398
7317
  const release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
7399
7318
  const asset = release.assets?.find((a) => a.name === ASSET_NAME);
7400
7319
  if (!asset) {
7401
7320
  throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
7402
7321
  }
7403
7322
  const dest = join3(pluginsFolder, ASSET_NAME);
7404
- console.log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
7323
+ log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
7405
7324
  await download(asset.browser_download_url, dest);
7406
- console.log(`Installed to ${dest}`);
7325
+ log(`Installed to ${dest}`);
7407
7326
  }
7408
7327
  var REPO, ASSET_NAME, OTHER_VARIANT, TIMEOUT_MS, MAX_REDIRECTS;
7409
7328
  var init_install_plugin = __esm({
@@ -7423,11 +7342,22 @@ init_dist();
7423
7342
  import { createRequire } from "module";
7424
7343
  if (process.argv.includes("--install-plugin")) {
7425
7344
  const { installPlugin: installPlugin2 } = await Promise.resolve().then(() => (init_install_plugin(), install_plugin_exports));
7426
- installPlugin2().catch((err) => {
7345
+ await installPlugin2().catch((err) => {
7427
7346
  console.error(err instanceof Error ? err.message : String(err));
7428
7347
  process.exitCode = 1;
7429
7348
  });
7430
7349
  } else {
7350
+ if (process.argv.includes("--auto-install-plugin")) {
7351
+ const { installBundledPlugin: installBundledPlugin2 } = await Promise.resolve().then(() => (init_install_plugin(), install_plugin_exports));
7352
+ await installBundledPlugin2({
7353
+ log: (message) => console.error(`[install-plugin] ${message}`),
7354
+ warn: (message) => console.error(message)
7355
+ }).catch((err) => {
7356
+ console.error(
7357
+ `[install-plugin] Auto-install skipped: ${err instanceof Error ? err.message : String(err)}`
7358
+ );
7359
+ });
7360
+ }
7431
7361
  const require2 = createRequire(import.meta.url);
7432
7362
  const { version: VERSION } = require2("../package.json");
7433
7363
  const server = new RobloxStudioMCPServer({