@chrrxs/robloxstudio-mcp-inspector 2.19.0 → 2.20.0

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
@@ -196,6 +196,9 @@ var init_bridge_service = __esm({
196
196
  }
197
197
  return removed;
198
198
  }
199
+ async unregisterInstanceIdEverywhere(instanceId) {
200
+ return this.unregisterInstanceId(instanceId);
201
+ }
199
202
  getInstances() {
200
203
  return Array.from(this.instances.values());
201
204
  }
@@ -451,13 +454,260 @@ var init_bridge_service = __esm({
451
454
  }
452
455
  });
453
456
 
457
+ // ../core/dist/roblox-docs.js
458
+ function isDocCategory(value) {
459
+ return DOC_CATEGORIES.includes(value);
460
+ }
461
+ function cacheGet(key) {
462
+ const entry = cache.get(key);
463
+ if (!entry)
464
+ return void 0;
465
+ const ttl = entry.notFound ? NEGATIVE_CACHE_TTL_MS : CACHE_TTL_MS;
466
+ if (Date.now() - entry.fetchedAt > ttl) {
467
+ cache.delete(key);
468
+ return void 0;
469
+ }
470
+ return entry;
471
+ }
472
+ function cacheSet(key, entry) {
473
+ if (cache.size >= MAX_CACHE_ENTRIES) {
474
+ const oldest = cache.keys().next().value;
475
+ if (oldest !== void 0)
476
+ cache.delete(oldest);
477
+ }
478
+ cache.set(key, entry);
479
+ }
480
+ function docUrl(category, name) {
481
+ return `${DOCS_BASE_URL}/${category}/${encodeURIComponent(name)}.md`;
482
+ }
483
+ async function fetchRobloxDoc(category, name) {
484
+ const key = `${category}/${name}`;
485
+ const cached = cacheGet(key);
486
+ if (cached) {
487
+ if (cached.notFound)
488
+ throw new DocNotFoundError(category, name);
489
+ return cached.content;
490
+ }
491
+ const controller = new AbortController();
492
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
493
+ let response;
494
+ try {
495
+ response = await fetch(docUrl(category, name), {
496
+ signal: controller.signal,
497
+ headers: { Accept: "text/markdown, text/plain" }
498
+ });
499
+ } catch (error) {
500
+ throw new Error(`Failed to fetch Roblox docs for ${key}: ${error instanceof Error ? error.message : String(error)}`);
501
+ } finally {
502
+ clearTimeout(timer);
503
+ }
504
+ if (response.status === 404) {
505
+ cacheSet(key, { fetchedAt: Date.now(), notFound: true });
506
+ throw new DocNotFoundError(category, name);
507
+ }
508
+ if (!response.ok) {
509
+ throw new Error(`Failed to fetch Roblox docs for ${key}: HTTP ${response.status}`);
510
+ }
511
+ const content = await response.text();
512
+ cacheSet(key, { fetchedAt: Date.now(), content });
513
+ return content;
514
+ }
515
+ function listSections(markdown) {
516
+ const sections = [];
517
+ for (const line of markdown.split("\n")) {
518
+ const match = line.match(/^##\s+(.+?)\s*$/);
519
+ if (match)
520
+ sections.push(match[1]);
521
+ }
522
+ return sections;
523
+ }
524
+ function extractSection(markdown, section) {
525
+ const lines = markdown.split("\n");
526
+ const wanted = section.trim().toLowerCase();
527
+ let start = -1;
528
+ for (let i = 0; i < lines.length; i++) {
529
+ const match = lines[i].match(/^##\s+(.+?)\s*$/);
530
+ if (match && match[1].toLowerCase() === wanted) {
531
+ start = i;
532
+ break;
533
+ }
534
+ }
535
+ if (start === -1)
536
+ return void 0;
537
+ let end = lines.length;
538
+ for (let i = start + 1; i < lines.length; i++) {
539
+ if (/^##?\s+/.test(lines[i])) {
540
+ end = i;
541
+ break;
542
+ }
543
+ }
544
+ return lines.slice(start, end).join("\n").trimEnd();
545
+ }
546
+ async function getRobloxDoc(category, name, section) {
547
+ const markdown = await fetchRobloxDoc(category, name);
548
+ const sections = listSections(markdown);
549
+ if (section) {
550
+ const extracted = extractSection(markdown, section);
551
+ if (extracted === void 0) {
552
+ throw new Error(`Section "${section}" not found in ${category}/${name}. Available sections: ${sections.join(", ") || "(none)"}`);
553
+ }
554
+ return { content: extracted, truncated: false, sections };
555
+ }
556
+ if (markdown.length > MAX_DOC_CHARS) {
557
+ const head = markdown.slice(0, MAX_DOC_CHARS);
558
+ const note = `
559
+
560
+ ---
561
+ [Truncated at ${MAX_DOC_CHARS} of ${markdown.length} characters. Re-request with the "section" parameter to read one section in full. Available sections: ${sections.join(", ")}]`;
562
+ return { content: head + note, truncated: true, sections };
563
+ }
564
+ return { content: markdown, truncated: false, sections };
565
+ }
566
+ var DOC_CATEGORIES, DOCS_BASE_URL, FETCH_TIMEOUT_MS, CACHE_TTL_MS, NEGATIVE_CACHE_TTL_MS, MAX_CACHE_ENTRIES, MAX_DOC_CHARS, cache, DocNotFoundError;
567
+ var init_roblox_docs = __esm({
568
+ "../core/dist/roblox-docs.js"() {
569
+ "use strict";
570
+ DOC_CATEGORIES = ["classes", "enums", "datatypes", "libraries", "globals"];
571
+ DOCS_BASE_URL = "https://create.roblox.com/docs/reference/engine";
572
+ FETCH_TIMEOUT_MS = 15e3;
573
+ CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
574
+ NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
575
+ MAX_CACHE_ENTRIES = 200;
576
+ MAX_DOC_CHARS = 5e4;
577
+ cache = /* @__PURE__ */ new Map();
578
+ DocNotFoundError = class extends Error {
579
+ constructor(category, name) {
580
+ super(`No Roblox documentation found for ${category}/${name}. Names are case-sensitive PascalCase (e.g. "ProximityPrompt", "TweenService"). Valid categories: ${DOC_CATEGORIES.join(", ")}.`);
581
+ this.name = "DocNotFoundError";
582
+ }
583
+ };
584
+ }
585
+ });
586
+
587
+ // ../core/dist/mcp-compat.js
588
+ import { ErrorCode, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
589
+ function registerResourceHandlers(server) {
590
+ server.registerCapabilities({ resources: {} });
591
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
592
+ resources: []
593
+ }));
594
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
595
+ resourceTemplates: [
596
+ {
597
+ uriTemplate: "robloxdocs://classes/{className}",
598
+ name: "Roblox class documentation",
599
+ description: "Official engine reference for a class (e.g. robloxdocs://classes/ProximityPrompt) \u2014 properties, methods, events, and usage guidance as markdown.",
600
+ mimeType: "text/markdown"
601
+ },
602
+ {
603
+ uriTemplate: "robloxdocs://enums/{enumName}",
604
+ name: "Roblox enum documentation",
605
+ description: "Official engine reference for an enum (e.g. robloxdocs://enums/KeyCode).",
606
+ mimeType: "text/markdown"
607
+ },
608
+ {
609
+ uriTemplate: "robloxdocs://datatypes/{dataTypeName}",
610
+ name: "Roblox datatype documentation",
611
+ description: "Official engine reference for a datatype (e.g. robloxdocs://datatypes/CFrame).",
612
+ mimeType: "text/markdown"
613
+ },
614
+ {
615
+ uriTemplate: "robloxdocs://libraries/{libraryName}",
616
+ name: "Roblox library documentation",
617
+ description: "Official engine reference for a Luau library (e.g. robloxdocs://libraries/table).",
618
+ mimeType: "text/markdown"
619
+ },
620
+ {
621
+ uriTemplate: "robloxdocs://globals/{globalsPage}",
622
+ name: "Roblox globals documentation",
623
+ description: "Official engine reference for global functions and variables (e.g. robloxdocs://globals/LuaGlobals, robloxdocs://globals/RobloxGlobals).",
624
+ mimeType: "text/markdown"
625
+ }
626
+ ]
627
+ }));
628
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
629
+ const uri = request.params.uri;
630
+ const match = uri.match(/^robloxdocs:\/\/([^/]+)\/([^/]+)$/);
631
+ if (!match || !isDocCategory(match[1])) {
632
+ throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`);
633
+ }
634
+ const [, category, rawName] = match;
635
+ const name = decodeURIComponent(rawName);
636
+ try {
637
+ const content = await fetchRobloxDoc(category, name);
638
+ return {
639
+ contents: [{ uri, mimeType: "text/markdown", text: content }]
640
+ };
641
+ } catch (error) {
642
+ if (error instanceof DocNotFoundError) {
643
+ throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found. Names are case-sensitive PascalCase; valid categories: ${DOC_CATEGORIES.join(", ")}.`);
644
+ }
645
+ throw new McpError(ErrorCode.InternalError, `Failed to read ${uri}: ${error instanceof Error ? error.message : String(error)}`);
646
+ }
647
+ });
648
+ }
649
+ var init_mcp_compat = __esm({
650
+ "../core/dist/mcp-compat.js"() {
651
+ "use strict";
652
+ init_roblox_docs();
653
+ }
654
+ });
655
+
454
656
  // ../core/dist/http-server.js
455
657
  import express from "express";
456
658
  import cors from "cors";
457
659
  import http from "http";
458
660
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
459
661
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
460
- import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
662
+ import { CallToolRequestSchema, ErrorCode as ErrorCode2, ListToolsRequestSchema, McpError as McpError2 } from "@modelcontextprotocol/sdk/types.js";
663
+ function parseLineRange(lineRange) {
664
+ const validLine = (line) => line === void 0 || line >= 1;
665
+ if (typeof lineRange === "string") {
666
+ const ranged = lineRange.match(/^\s*(\d+)?\s*[-:]\s*(\d+)?\s*$/);
667
+ if (ranged) {
668
+ const s = ranged[1] !== void 0 ? parseInt(ranged[1], 10) : void 0;
669
+ const e = ranged[2] !== void 0 ? parseInt(ranged[2], 10) : void 0;
670
+ if (!validLine(s) || !validLine(e))
671
+ return void 0;
672
+ if (s !== void 0 && e !== void 0 && s > e)
673
+ return void 0;
674
+ if (s !== void 0 || e !== void 0)
675
+ return { startLine: s, endLine: e };
676
+ }
677
+ const single = lineRange.match(/^\s*(\d+)\s*$/);
678
+ if (single) {
679
+ const n = parseInt(single[1], 10);
680
+ if (n < 1)
681
+ return void 0;
682
+ return { startLine: n, endLine: n };
683
+ }
684
+ }
685
+ return void 0;
686
+ }
687
+ function optionalLineRange(body, toolName) {
688
+ if (body.line_range === void 0)
689
+ return {};
690
+ const parsed = parseLineRange(body.line_range);
691
+ if (!parsed)
692
+ throw new Error(`${toolName} line_range must be a string like "42", "10-20", "10-", or "-20"`);
693
+ return parsed;
694
+ }
695
+ function optionalLineAnchor(body, toolName) {
696
+ const parsed = optionalLineRange(body, toolName);
697
+ if (parsed.startLine === void 0 && parsed.endLine === void 0)
698
+ return void 0;
699
+ if (parsed.startLine === void 0 || parsed.endLine === void 0 || parsed.endLine !== parsed.startLine) {
700
+ throw new Error(`${toolName} line_range must be a single line like "42"`);
701
+ }
702
+ return parsed.startLine;
703
+ }
704
+ function requiredClosedLineRange(body, toolName) {
705
+ const parsed = optionalLineRange(body, toolName);
706
+ if (parsed.startLine === void 0 || parsed.endLine === void 0) {
707
+ throw new Error(`${toolName} requires line_range as "start-end" or a single line like "42"`);
708
+ }
709
+ return { startLine: parsed.startLine, endLine: parsed.endLine };
710
+ }
461
711
  function createHttpServer(tools, bridge, allowedTools, serverConfig) {
462
712
  const app = express();
463
713
  let mcpServerActive = false;
@@ -590,6 +840,15 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
590
840
  }
591
841
  res.json({ success: true });
592
842
  });
843
+ app.post("/unregister-instance-id", (req, res) => {
844
+ const { instanceId } = req.body;
845
+ if (typeof instanceId !== "string" || instanceId.length === 0) {
846
+ res.status(400).json({ error: "instanceId is required" });
847
+ return;
848
+ }
849
+ const removed = bridge.unregisterInstanceId(instanceId);
850
+ res.json({ success: true, removed });
851
+ });
593
852
  app.get("/status", (req, res) => {
594
853
  const instances = bridge.getInstances();
595
854
  const publicInstances = instances.map(toPublic);
@@ -707,6 +966,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
707
966
  try {
708
967
  trackMCPActivity();
709
968
  const server = new Server({ name: serverConfig.name, version: serverConfig.version }, { capabilities: { tools: {} } });
969
+ registerResourceHandlers(server);
710
970
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
711
971
  tools: filteredTools.map((t) => ({
712
972
  name: t.name,
@@ -717,11 +977,11 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
717
977
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
718
978
  const { name, arguments: args } = request.params;
719
979
  if (allowedTools && !allowedTools.has(name)) {
720
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
980
+ throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${name}`);
721
981
  }
722
982
  const handler = TOOL_HANDLERS[name];
723
983
  if (!handler) {
724
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
984
+ throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${name}`);
725
985
  }
726
986
  try {
727
987
  return await handler(tools, args || {});
@@ -739,9 +999,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
739
999
  isError: true
740
1000
  };
741
1001
  }
742
- if (error instanceof McpError)
1002
+ if (error instanceof McpError2)
743
1003
  throw error;
744
- throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`);
1004
+ throw new McpError2(ErrorCode2.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`);
745
1005
  }
746
1006
  });
747
1007
  const transport = new StreamableHTTPServerTransport({
@@ -847,7 +1107,9 @@ var init_http_server = __esm({
847
1107
  "../core/dist/http-server.js"() {
848
1108
  "use strict";
849
1109
  init_bridge_service();
1110
+ init_mcp_compat();
850
1111
  TOOL_HANDLERS = {
1112
+ get_roblox_docs: (tools, body) => tools.getRobloxDocs(body.name, body.doc_type, body.section),
851
1113
  get_file_tree: (tools, body) => tools.getFileTree(body.path, body.instance_id),
852
1114
  search_files: (tools, body) => tools.searchFiles(body.query, body.searchType, body.instance_id),
853
1115
  get_place_info: (tools, body) => tools.getPlaceInfo(body.instance_id),
@@ -877,11 +1139,17 @@ var init_http_server = __esm({
877
1139
  path: body.path,
878
1140
  classFilter: body.classFilter
879
1141
  }, body.instance_id),
880
- get_script_source: (tools, body) => tools.getScriptSource(body.instancePath, body.startLine, body.endLine, body.instance_id),
1142
+ get_script_source: (tools, body) => {
1143
+ const { startLine, endLine } = optionalLineRange(body, "get_script_source");
1144
+ return tools.getScriptSource(body.instancePath, startLine, endLine, body.instance_id);
1145
+ },
881
1146
  set_script_source: (tools, body) => tools.setScriptSource(body.instancePath, body.source, body.instance_id),
882
- edit_script_lines: (tools, body) => tools.editScriptLines(body.instancePath, body.old_string, body.new_string, body.startLine, body.instance_id),
1147
+ edit_script_lines: (tools, body) => tools.editScriptLines(body.instancePath, body.old_string, body.new_string, optionalLineAnchor(body, "edit_script_lines"), body.instance_id),
883
1148
  insert_script_lines: (tools, body) => tools.insertScriptLines(body.instancePath, body.afterLine, body.newContent, body.instance_id),
884
- delete_script_lines: (tools, body) => tools.deleteScriptLines(body.instancePath, body.startLine, body.endLine, body.instance_id),
1149
+ delete_script_lines: (tools, body) => {
1150
+ const { startLine, endLine } = requiredClosedLineRange(body, "delete_script_lines");
1151
+ return tools.deleteScriptLines(body.instancePath, startLine, endLine, body.instance_id);
1152
+ },
885
1153
  set_attribute: (tools, body) => tools.setAttribute(body.instancePath, body.attributeName, body.attributeValue, body.valueType, body.instance_id),
886
1154
  get_attributes: (tools, body) => tools.getAttributes(body.instancePath, body.instance_id),
887
1155
  delete_attribute: (tools, body) => tools.deleteAttribute(body.instancePath, body.attributeName, body.instance_id),
@@ -903,8 +1171,8 @@ var init_http_server = __esm({
903
1171
  solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
904
1172
  start_playtest: (tools, body) => tools.startPlaytest(body.mode, body.numPlayers, body.instance_id),
905
1173
  stop_playtest: (tools, body) => tools.stopPlaytest(body.instance_id),
906
- multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
907
- multiplayer_test_start: (tools, body) => tools.multiplayerTestStart(body.numPlayers, body.testArgs, body.timeout, body.instance_id),
1174
+ multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id, body.force),
1175
+ multiplayer_test_start: (tools, body) => tools.multiplayerTestStart(body.numPlayers, body.testArgs, body.timeout, body.instance_id, body.force),
908
1176
  multiplayer_test_state: (tools, body) => tools.multiplayerTestState(body.instance_id),
909
1177
  multiplayer_test_add_players: (tools, body) => tools.multiplayerTestAddPlayers(body.numPlayers, body.timeout, body.instance_id),
910
1178
  multiplayer_test_leave_client: (tools, body) => tools.multiplayerTestLeaveClient(body.target, body.timeout, body.instance_id),
@@ -1739,11 +2007,300 @@ var init_roblox_cookie_client = __esm({
1739
2007
  }
1740
2008
  });
1741
2009
 
1742
- // ../core/dist/studio-instance-manager.js
1743
- import { execFileSync, spawn } from "child_process";
1744
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
2010
+ // ../core/dist/managed-instance-registry.js
2011
+ import * as fs from "fs";
1745
2012
  import * as os from "os";
1746
2013
  import * as path from "path";
2014
+ function defaultManagedInstanceRegistryDir() {
2015
+ if (process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR) {
2016
+ return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
2017
+ }
2018
+ if (process.platform === "win32" && process.env.LOCALAPPDATA) {
2019
+ return path.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
2020
+ }
2021
+ if (process.platform === "darwin") {
2022
+ return path.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
2023
+ }
2024
+ return path.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
2025
+ }
2026
+ function sleepSync(ms) {
2027
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2028
+ }
2029
+ function ymd(timestamp) {
2030
+ return new Date(timestamp).toISOString().slice(0, 10);
2031
+ }
2032
+ function eventLogDate(name) {
2033
+ const match = name.match(/^events-(\d{4}-\d{2}-\d{2})\.jsonl$/);
2034
+ return match?.[1];
2035
+ }
2036
+ function isRecord(value) {
2037
+ if (!value || typeof value !== "object")
2038
+ return false;
2039
+ const record = value;
2040
+ return record.version === REGISTRY_VERSION && typeof record.recordId === "string" && typeof record.source === "string" && typeof record.exe === "string" && Array.isArray(record.args) && typeof record.launchedAt === "number" && typeof record.bootId === "string";
2041
+ }
2042
+ var REGISTRY_VERSION, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, EVENT_RETENTION_DAYS, ManagedInstanceRegistry;
2043
+ var init_managed_instance_registry = __esm({
2044
+ "../core/dist/managed-instance-registry.js"() {
2045
+ "use strict";
2046
+ REGISTRY_VERSION = 1;
2047
+ LOCK_STALE_MS = 1e4;
2048
+ LOCK_RETRY_MS = 25;
2049
+ LOCK_TIMEOUT_MS = 5e3;
2050
+ EVENT_RETENTION_DAYS = 2;
2051
+ ManagedInstanceRegistry = class {
2052
+ dir;
2053
+ constructor(dir = defaultManagedInstanceRegistryDir()) {
2054
+ this.dir = dir;
2055
+ }
2056
+ upsert(record) {
2057
+ this.withLock(() => this.writeRecordUnlocked(record));
2058
+ }
2059
+ attachInstanceId(recordId, instanceId) {
2060
+ this.withLock(() => {
2061
+ const record = this.readRecordUnlocked(recordId);
2062
+ if (!record)
2063
+ return;
2064
+ record.instanceId = instanceId;
2065
+ record.attachedAt = Date.now();
2066
+ this.writeRecordUnlocked(record);
2067
+ });
2068
+ }
2069
+ findOpenByInstanceId(instanceId, options) {
2070
+ return this.withLock(() => {
2071
+ this.sweepUnlocked(options);
2072
+ return this.readOpenRecordsUnlocked().find((record) => record.instanceId === instanceId);
2073
+ });
2074
+ }
2075
+ findAnyByInstanceId(instanceId) {
2076
+ return this.withLock(() => this.readRecordsUnlocked().find((record) => record.instanceId === instanceId));
2077
+ }
2078
+ listOpen(options) {
2079
+ return this.withLock(() => {
2080
+ this.sweepUnlocked(options);
2081
+ return this.readOpenRecordsUnlocked();
2082
+ });
2083
+ }
2084
+ markClosed(recordId, closedAt = Date.now()) {
2085
+ this.withLock(() => {
2086
+ const record = this.readRecordUnlocked(recordId);
2087
+ if (!record)
2088
+ return;
2089
+ record.closedAt = closedAt;
2090
+ this.writeRecordUnlocked(record);
2091
+ });
2092
+ }
2093
+ delete(recordId) {
2094
+ this.withLock(() => this.deleteRecordUnlocked(recordId));
2095
+ }
2096
+ sweep(options) {
2097
+ this.withLock(() => this.sweepUnlocked(options));
2098
+ }
2099
+ logEvent(event, now = Date.now()) {
2100
+ this.withLock(() => this.appendEventUnlocked(event, now));
2101
+ }
2102
+ withLock(fn) {
2103
+ this.ensureDir();
2104
+ const lockDir = path.join(this.dir, ".lock");
2105
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
2106
+ while (true) {
2107
+ try {
2108
+ fs.mkdirSync(lockDir);
2109
+ break;
2110
+ } catch (error) {
2111
+ const code = error.code;
2112
+ if (code !== "EEXIST")
2113
+ throw error;
2114
+ try {
2115
+ const stat = fs.statSync(lockDir);
2116
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
2117
+ fs.rmSync(lockDir, { recursive: true, force: true });
2118
+ continue;
2119
+ }
2120
+ } catch {
2121
+ continue;
2122
+ }
2123
+ if (Date.now() > deadline) {
2124
+ throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
2125
+ }
2126
+ sleepSync(LOCK_RETRY_MS);
2127
+ }
2128
+ }
2129
+ try {
2130
+ return fn();
2131
+ } finally {
2132
+ fs.rmSync(lockDir, { recursive: true, force: true });
2133
+ }
2134
+ }
2135
+ ensureDir() {
2136
+ fs.mkdirSync(this.dir, { recursive: true });
2137
+ }
2138
+ recordPath(recordId) {
2139
+ return path.join(this.dir, `${recordId}.json`);
2140
+ }
2141
+ recordFilesUnlocked() {
2142
+ return fs.readdirSync(this.dir).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
2143
+ }
2144
+ readRecordUnlocked(recordId) {
2145
+ try {
2146
+ const parsed = JSON.parse(fs.readFileSync(this.recordPath(recordId), "utf8"));
2147
+ return isRecord(parsed) ? parsed : void 0;
2148
+ } catch {
2149
+ return void 0;
2150
+ }
2151
+ }
2152
+ readOpenRecordsUnlocked() {
2153
+ return this.readRecordsUnlocked().filter((record) => record.closedAt === void 0);
2154
+ }
2155
+ readRecordsUnlocked() {
2156
+ const records = [];
2157
+ for (const file of this.recordFilesUnlocked()) {
2158
+ try {
2159
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
2160
+ if (!isRecord(parsed))
2161
+ continue;
2162
+ records.push(parsed);
2163
+ } catch {
2164
+ }
2165
+ }
2166
+ return records;
2167
+ }
2168
+ writeRecordUnlocked(record) {
2169
+ this.ensureDir();
2170
+ const finalPath = this.recordPath(record.recordId);
2171
+ const tmpPath = path.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
2172
+ const fd = fs.openSync(tmpPath, "w");
2173
+ try {
2174
+ fs.writeFileSync(fd, `${JSON.stringify(record, null, 2)}
2175
+ `, "utf8");
2176
+ fs.fsyncSync(fd);
2177
+ } finally {
2178
+ fs.closeSync(fd);
2179
+ }
2180
+ fs.renameSync(tmpPath, finalPath);
2181
+ }
2182
+ deleteRecordUnlocked(recordId) {
2183
+ fs.rmSync(this.recordPath(recordId), { force: true });
2184
+ }
2185
+ appendEventUnlocked(event, now) {
2186
+ const file = path.join(this.dir, `events-${ymd(now)}.jsonl`);
2187
+ fs.appendFileSync(file, `${JSON.stringify({
2188
+ ts: new Date(now).toISOString(),
2189
+ ...event
2190
+ })}
2191
+ `, "utf8");
2192
+ }
2193
+ cleanupOldEventLogsUnlocked(now) {
2194
+ const cutoff = ymd(now - EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1e3);
2195
+ for (const name of fs.readdirSync(this.dir)) {
2196
+ const date = eventLogDate(name);
2197
+ if (!date || date >= cutoff)
2198
+ continue;
2199
+ try {
2200
+ fs.rmSync(path.join(this.dir, name), { force: true });
2201
+ } catch {
2202
+ }
2203
+ }
2204
+ }
2205
+ cleanupRecord(options, record) {
2206
+ try {
2207
+ options.cleanupRecord?.(record);
2208
+ } catch {
2209
+ this.appendEventUnlocked({
2210
+ event: "registry_cleanup_failed",
2211
+ recordId: record.recordId,
2212
+ instanceId: record.instanceId,
2213
+ source: record.source,
2214
+ reason: "cleanup_record_error"
2215
+ }, options.now ?? Date.now());
2216
+ }
2217
+ }
2218
+ sweepUnlocked(options) {
2219
+ const now = options.now ?? Date.now();
2220
+ this.cleanupOldEventLogsUnlocked(now);
2221
+ for (const file of this.recordFilesUnlocked()) {
2222
+ let parsed;
2223
+ try {
2224
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
2225
+ } catch {
2226
+ fs.rmSync(file, { force: true });
2227
+ this.appendEventUnlocked({
2228
+ event: "registry_pruned_malformed_record",
2229
+ reason: "parse_error",
2230
+ action: "deleted_record"
2231
+ }, now);
2232
+ continue;
2233
+ }
2234
+ if (!parsed || typeof parsed !== "object") {
2235
+ fs.rmSync(file, { force: true });
2236
+ this.appendEventUnlocked({
2237
+ event: "registry_pruned_malformed_record",
2238
+ reason: "invalid_shape",
2239
+ action: "deleted_record"
2240
+ }, now);
2241
+ continue;
2242
+ }
2243
+ const version = parsed.version;
2244
+ if (typeof version === "number" && version > REGISTRY_VERSION)
2245
+ continue;
2246
+ if (!isRecord(parsed)) {
2247
+ fs.rmSync(file, { force: true });
2248
+ this.appendEventUnlocked({
2249
+ event: "registry_pruned_malformed_record",
2250
+ reason: "invalid_shape",
2251
+ action: "deleted_record"
2252
+ }, now);
2253
+ continue;
2254
+ }
2255
+ if (parsed.closedAt !== void 0) {
2256
+ fs.rmSync(file, { force: true });
2257
+ this.appendEventUnlocked({
2258
+ event: "registry_pruned_closed_record",
2259
+ recordId: parsed.recordId,
2260
+ instanceId: parsed.instanceId,
2261
+ source: parsed.source,
2262
+ reason: "closed_at_present",
2263
+ action: "deleted_record"
2264
+ }, now);
2265
+ continue;
2266
+ }
2267
+ if (parsed.bootId !== options.currentBootId) {
2268
+ this.cleanupRecord(options, parsed);
2269
+ fs.rmSync(file, { force: true });
2270
+ this.appendEventUnlocked({
2271
+ event: "registry_pruned_previous_boot",
2272
+ recordId: parsed.recordId,
2273
+ instanceId: parsed.instanceId,
2274
+ source: parsed.source,
2275
+ reason: "boot_id_changed",
2276
+ action: "deleted_record_and_cleaned_baseplate"
2277
+ }, now);
2278
+ continue;
2279
+ }
2280
+ if (options.isProcessRunning && (parsed.nativeProcessId || parsed.spawnPid) && !options.isProcessRunning(parsed)) {
2281
+ this.cleanupRecord(options, parsed);
2282
+ fs.rmSync(file, { force: true });
2283
+ this.appendEventUnlocked({
2284
+ event: "registry_pruned_stale_process",
2285
+ recordId: parsed.recordId,
2286
+ instanceId: parsed.instanceId,
2287
+ source: parsed.source,
2288
+ reason: "pid_not_running",
2289
+ action: "deleted_record_and_cleaned_baseplate"
2290
+ }, now);
2291
+ }
2292
+ }
2293
+ }
2294
+ };
2295
+ }
2296
+ });
2297
+
2298
+ // ../core/dist/studio-instance-manager.js
2299
+ import { execFileSync, spawn } from "child_process";
2300
+ import { copyFileSync, existsSync, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, realpathSync, rmSync as rmSync2, statSync as statSync2 } from "fs";
2301
+ import { randomUUID } from "crypto";
2302
+ import * as os2 from "os";
2303
+ import * as path2 from "path";
1747
2304
  function run(command, args, options = {}) {
1748
2305
  return execFileSync(command, args, {
1749
2306
  encoding: "utf8",
@@ -1755,7 +2312,7 @@ function isWsl() {
1755
2312
  if (process.platform !== "linux")
1756
2313
  return false;
1757
2314
  try {
1758
- return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
2315
+ return /microsoft|wsl/i.test(readFileSync2("/proc/version", "utf8"));
1759
2316
  } catch {
1760
2317
  return false;
1761
2318
  }
@@ -1784,7 +2341,7 @@ function toWslPath(windowsPath) {
1784
2341
  return run("wslpath", ["-u", windowsPath]);
1785
2342
  }
1786
2343
  function toStudioLaunchArg(arg) {
1787
- if (!isWsl() || !path.isAbsolute(arg) || !existsSync(arg))
2344
+ if (!isWsl() || !path2.isAbsolute(arg) || !existsSync(arg))
1788
2345
  return arg;
1789
2346
  return run("wslpath", ["-w", arg]);
1790
2347
  }
@@ -1793,21 +2350,21 @@ function resolveEntrypointDir() {
1793
2350
  if (!entrypoint)
1794
2351
  return void 0;
1795
2352
  try {
1796
- return path.dirname(realpathSync(entrypoint));
2353
+ return path2.dirname(realpathSync(entrypoint));
1797
2354
  } catch {
1798
- return path.dirname(path.resolve(entrypoint));
2355
+ return path2.dirname(path2.resolve(entrypoint));
1799
2356
  }
1800
2357
  }
1801
2358
  function resolveBaseplateTemplatePath() {
1802
2359
  const entrypointDir = resolveEntrypointDir();
1803
2360
  const candidates = [
1804
2361
  ...entrypointDir ? [
1805
- path.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
1806
- path.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
2362
+ path2.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
2363
+ path2.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
1807
2364
  ] : [],
1808
- path.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
1809
- path.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
1810
- path.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
2365
+ path2.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
2366
+ path2.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
2367
+ path2.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
1811
2368
  ];
1812
2369
  for (const candidate of candidates) {
1813
2370
  if (existsSync(candidate))
@@ -1816,22 +2373,22 @@ function resolveBaseplateTemplatePath() {
1816
2373
  throw new Error(`Baseplate template not found. Expected ${BASEPLATE_TEMPLATE_NAME} in one of: ${candidates.join(", ")}`);
1817
2374
  }
1818
2375
  function createBaseplatePlaceFile() {
1819
- mkdirSync(BASEPLATE_TEMP_DIR, { recursive: true });
1820
- const file = path.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
2376
+ mkdirSync2(BASEPLATE_TEMP_DIR, { recursive: true });
2377
+ const file = path2.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
1821
2378
  copyFileSync(resolveBaseplateTemplatePath(), file);
1822
2379
  return file;
1823
2380
  }
1824
2381
  function isGeneratedBaseplatePlaceFile(file) {
1825
- const resolvedFile = path.resolve(file);
1826
- return path.dirname(resolvedFile) === path.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path.basename(resolvedFile));
2382
+ const resolvedFile = path2.resolve(file);
2383
+ return path2.dirname(resolvedFile) === path2.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path2.basename(resolvedFile));
1827
2384
  }
1828
2385
  function cleanupManagedBaseplateFiles(record) {
1829
2386
  if (record.source !== "baseplate" || !record.localPlaceFile)
1830
2387
  return;
1831
2388
  if (!isGeneratedBaseplatePlaceFile(record.localPlaceFile))
1832
2389
  return;
1833
- rmSync(record.localPlaceFile, { force: true });
1834
- rmSync(`${record.localPlaceFile}.lock`, { force: true });
2390
+ rmSync2(record.localPlaceFile, { force: true });
2391
+ rmSync2(`${record.localPlaceFile}.lock`, { force: true });
1835
2392
  }
1836
2393
  function prepareStudioLaunchOptions(options) {
1837
2394
  if (options.source !== "baseplate" || options.localPlaceFile)
@@ -1851,11 +2408,11 @@ function resolveStudioExe() {
1851
2408
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
1852
2409
  }
1853
2410
  const localAppData = windowsLocalAppData();
1854
- const root = localAppData ? path.join(toWslPath(localAppData), "Roblox", "Versions") : path.join(os.homedir(), "AppData", "Local", "Roblox", "Versions");
2411
+ const root = localAppData ? path2.join(toWslPath(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
1855
2412
  if (!existsSync(root)) {
1856
2413
  throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
1857
2414
  }
1858
- const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
2415
+ const candidates = readdirSync2(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync(candidate)).sort((a, b) => statSync2(b).mtimeMs - statSync2(a).mtimeMs);
1859
2416
  if (candidates.length === 0) {
1860
2417
  throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
1861
2418
  }
@@ -1871,14 +2428,14 @@ function listStudioProcesses() {
1871
2428
  }
1872
2429
  return out2.split("\n").filter(Boolean).map((line) => {
1873
2430
  const [pid, ...rest] = line.trim().split(/\s+/);
1874
- return { Id: Number(pid), Path: rest.join(" "), MainWindowTitle: "" };
2431
+ return { Id: Number(pid), Name: "RobloxStudio", Path: rest.join(" "), MainWindowTitle: "" };
1875
2432
  }).filter((proc) => Number.isFinite(proc.Id));
1876
2433
  }
1877
2434
  if (process.platform !== "win32" && !isWsl())
1878
2435
  return [];
1879
2436
  let out = "";
1880
2437
  try {
1881
- out = powershell("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | Select-Object Id,Path,MainWindowTitle | ConvertTo-Json -Compress");
2438
+ out = powershell("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | Select-Object Id,Name,Path,MainWindowTitle | ConvertTo-Json -Compress");
1882
2439
  } catch {
1883
2440
  return [];
1884
2441
  }
@@ -1887,6 +2444,27 @@ function listStudioProcesses() {
1887
2444
  const parsed = JSON.parse(out);
1888
2445
  return Array.isArray(parsed) ? parsed : [parsed];
1889
2446
  }
2447
+ function currentBootId() {
2448
+ if (process.platform === "linux") {
2449
+ try {
2450
+ return readFileSync2("/proc/sys/kernel/random/boot_id", "utf8").trim();
2451
+ } catch {
2452
+ }
2453
+ }
2454
+ if (process.platform === "win32" || isWsl()) {
2455
+ try {
2456
+ return powershell('(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString("o")');
2457
+ } catch {
2458
+ }
2459
+ }
2460
+ if (process.platform === "darwin") {
2461
+ try {
2462
+ return run("sysctl", ["-n", "kern.boottime"]);
2463
+ } catch {
2464
+ }
2465
+ }
2466
+ return `${process.platform}:${os2.hostname()}:unknown-boot`;
2467
+ }
1890
2468
  function buildStudioLaunchArgs(options) {
1891
2469
  switch (options.source) {
1892
2470
  case "baseplate":
@@ -1899,13 +2477,13 @@ function buildStudioLaunchArgs(options) {
1899
2477
  if (!options.placeId)
1900
2478
  throw new Error('place_id is required when source="published_place".');
1901
2479
  if (!options.universeId)
1902
- throw new Error('universe_id is required when source="published_place".');
2480
+ throw new Error('Derived universe id is required when source="published_place".');
1903
2481
  return ["--task", "EditPlace", "--placeId", String(options.placeId), "--universeId", String(options.universeId)];
1904
2482
  case "place_revision":
1905
2483
  if (!options.placeId)
1906
2484
  throw new Error('place_id is required when source="place_revision".');
1907
2485
  if (!options.universeId)
1908
- throw new Error('universe_id is required when source="place_revision".');
2486
+ throw new Error('Derived universe id is required when source="place_revision".');
1909
2487
  if (!options.placeVersion)
1910
2488
  throw new Error('place_version is required when launching source="place_revision".');
1911
2489
  return [
@@ -1923,39 +2501,69 @@ function buildStudioLaunchArgs(options) {
1923
2501
  function delay(ms) {
1924
2502
  return new Promise((resolve4) => setTimeout(resolve4, ms));
1925
2503
  }
2504
+ function basenameAny(filePath) {
2505
+ return path2.basename(filePath.replace(/\\/g, "/"));
2506
+ }
1926
2507
  var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, StudioInstanceManager;
1927
2508
  var init_studio_instance_manager = __esm({
1928
2509
  "../core/dist/studio-instance-manager.js"() {
1929
2510
  "use strict";
1930
- BASEPLATE_TEMP_DIR = path.join(os.tmpdir(), "robloxstudio-mcp-baseplates");
2511
+ init_managed_instance_registry();
2512
+ BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
1931
2513
  BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
1932
2514
  BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
1933
2515
  StudioInstanceManager = class {
1934
2516
  managedByInstanceId = /* @__PURE__ */ new Map();
1935
2517
  pending = /* @__PURE__ */ new Set();
2518
+ registry;
2519
+ processAdapter;
2520
+ constructor(options = {}) {
2521
+ this.registry = options.registry ?? new ManagedInstanceRegistry(options.registryDir);
2522
+ this.processAdapter = options.processAdapter ?? {};
2523
+ }
1936
2524
  list() {
1937
- return [...this.managedByInstanceId.values(), ...this.pending].filter((instance, index, all) => all.indexOf(instance) === index);
2525
+ this.sweepRegistry();
2526
+ const records = [...this.managedByInstanceId.values(), ...this.pending];
2527
+ for (const registryRecord of this.registry.listOpen(this.registrySweepOptions())) {
2528
+ const record = this.fromRegistryRecord(registryRecord);
2529
+ if (records.some((existing) => record.recordId && existing.recordId === record.recordId || record.instanceId && existing.instanceId === record.instanceId)) {
2530
+ continue;
2531
+ }
2532
+ records.push(record);
2533
+ }
2534
+ return records.filter((instance, index, all) => all.indexOf(instance) === index);
1938
2535
  }
1939
2536
  get(instanceId) {
1940
- return this.managedByInstanceId.get(instanceId);
2537
+ this.sweepRegistry();
2538
+ const memoryRecord = this.managedByInstanceId.get(instanceId);
2539
+ if (memoryRecord)
2540
+ return memoryRecord;
2541
+ const registryRecord = this.registry.findOpenByInstanceId(instanceId, this.registrySweepOptions());
2542
+ return registryRecord ? this.fromRegistryRecord(registryRecord) : void 0;
1941
2543
  }
1942
2544
  attachInstanceId(record, instanceId) {
1943
2545
  record.instanceId = instanceId;
1944
2546
  this.pending.delete(record);
1945
2547
  this.managedByInstanceId.set(instanceId, record);
2548
+ if (record.recordId) {
2549
+ this.registry.attachInstanceId(record.recordId, instanceId);
2550
+ }
1946
2551
  }
1947
2552
  async launch(options) {
2553
+ this.sweepRegistry();
1948
2554
  const preparedOptions = prepareStudioLaunchOptions(options);
1949
- const before = new Set(listStudioProcesses().map((proc2) => proc2.Id));
1950
- const exe = resolveStudioExe();
2555
+ const before = new Set(this.listStudioProcesses().map((proc2) => proc2.Id));
2556
+ const exe = this.processAdapter.resolveStudioExe?.() ?? resolveStudioExe();
1951
2557
  const args = buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArg);
1952
- const proc = spawn(exe, args, {
2558
+ const spawnOptions = {
1953
2559
  cwd: isWsl() && existsSync("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
1954
2560
  detached: true,
1955
2561
  stdio: "ignore"
1956
- });
2562
+ };
2563
+ const proc = this.processAdapter.spawnStudio ? this.processAdapter.spawnStudio(exe, args, spawnOptions) : spawn(exe, args, spawnOptions);
1957
2564
  proc.unref();
1958
2565
  const record = {
2566
+ recordId: randomUUID(),
1959
2567
  source: options.source,
1960
2568
  spawnPid: proc.pid,
1961
2569
  exe,
@@ -1964,43 +2572,119 @@ var init_studio_instance_manager = __esm({
1964
2572
  universeId: preparedOptions.universeId,
1965
2573
  placeVersion: preparedOptions.placeVersion,
1966
2574
  localPlaceFile: preparedOptions.localPlaceFile,
1967
- launchedAt: Date.now()
2575
+ launchedAt: Date.now(),
2576
+ ownerPid: process.pid,
2577
+ bootId: this.getCurrentBootId(),
2578
+ deleteLocalPlaceFileOnClose: options.source === "baseplate"
1968
2579
  };
1969
2580
  this.pending.add(record);
2581
+ this.registry.upsert(this.toRegistryRecord(record));
1970
2582
  const deadline = Date.now() + 5e3;
1971
2583
  while (Date.now() < deadline && record.nativeProcessId === void 0) {
1972
- const created = listStudioProcesses().find((candidate) => !before.has(candidate.Id));
2584
+ const created = this.listStudioProcesses().find((candidate) => !before.has(candidate.Id));
1973
2585
  if (created) {
1974
2586
  record.nativeProcessId = created.Id;
2587
+ this.registry.upsert(this.toRegistryRecord(record));
1975
2588
  break;
1976
2589
  }
1977
2590
  await delay(250);
1978
2591
  }
1979
2592
  if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
1980
2593
  record.nativeProcessId = proc.pid;
2594
+ this.registry.upsert(this.toRegistryRecord(record));
1981
2595
  }
1982
2596
  return record;
1983
2597
  }
2598
+ closeByInstanceId(instanceId) {
2599
+ const memoryRecord = this.managedByInstanceId.get(instanceId);
2600
+ if (memoryRecord)
2601
+ return this.close(memoryRecord);
2602
+ const registryRecord = this.registry.findAnyByInstanceId(instanceId);
2603
+ if (!registryRecord) {
2604
+ this.sweepRegistry();
2605
+ return { status: "not_found", instanceId };
2606
+ }
2607
+ if (registryRecord.closedAt !== void 0) {
2608
+ this.cleanupManagedRecord(registryRecord);
2609
+ this.registry.delete(registryRecord.recordId);
2610
+ this.registry.logEvent({
2611
+ event: "registry_close_already_stopped",
2612
+ recordId: registryRecord.recordId,
2613
+ instanceId: registryRecord.instanceId,
2614
+ source: registryRecord.source,
2615
+ reason: "closed_at_present",
2616
+ action: "deleted_record_and_cleaned_baseplate"
2617
+ });
2618
+ return { status: "already_closed", instanceId };
2619
+ }
2620
+ if (registryRecord.bootId !== this.getCurrentBootId()) {
2621
+ this.cleanupManagedRecord(registryRecord);
2622
+ this.registry.delete(registryRecord.recordId);
2623
+ this.registry.logEvent({
2624
+ event: "registry_pruned_previous_boot",
2625
+ recordId: registryRecord.recordId,
2626
+ instanceId: registryRecord.instanceId,
2627
+ source: registryRecord.source,
2628
+ reason: "boot_id_changed",
2629
+ action: "deleted_record_and_cleaned_baseplate"
2630
+ });
2631
+ return { status: "already_closed", instanceId };
2632
+ }
2633
+ return this.close(this.fromRegistryRecord(registryRecord));
2634
+ }
1984
2635
  close(record) {
1985
2636
  const processId = record.nativeProcessId ?? record.spawnPid;
1986
2637
  if (!processId) {
1987
2638
  throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
1988
2639
  }
1989
- if (process.platform === "win32" || isWsl()) {
1990
- powershell(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
1991
- } else {
1992
- try {
1993
- process.kill(processId, "SIGTERM");
1994
- } catch (error) {
1995
- if (error.code !== "ESRCH")
1996
- throw error;
1997
- }
2640
+ const studioProcess = this.findProcessById(processId);
2641
+ if (!studioProcess) {
2642
+ this.cleanupManagedRecord(record);
2643
+ this.markClosedInMemory(record);
2644
+ this.markClosedInRegistry(record);
2645
+ this.registry.logEvent({
2646
+ event: "registry_close_already_stopped",
2647
+ recordId: record.recordId,
2648
+ instanceId: record.instanceId,
2649
+ source: record.source,
2650
+ reason: "pid_not_running",
2651
+ action: "marked_closed_and_cleaned_baseplate"
2652
+ });
2653
+ return { status: "already_closed", instanceId: record.instanceId };
2654
+ }
2655
+ if (!this.verifyProcessForRecord(record, studioProcess)) {
2656
+ this.registry.logEvent({
2657
+ event: "registry_process_verification_failed",
2658
+ recordId: record.recordId,
2659
+ instanceId: record.instanceId,
2660
+ source: record.source,
2661
+ reason: "identity_mismatch"
2662
+ });
2663
+ throw new Error("Managed Studio process identity could not be verified.");
2664
+ }
2665
+ try {
2666
+ this.closeProcess(processId);
2667
+ } catch (error) {
2668
+ if (this.findProcessById(processId))
2669
+ throw error;
2670
+ this.registry.logEvent({
2671
+ event: "registry_close_already_stopped",
2672
+ recordId: record.recordId,
2673
+ instanceId: record.instanceId,
2674
+ source: record.source,
2675
+ reason: "stop_raced_with_exit",
2676
+ action: "marked_closed_and_cleaned_baseplate"
2677
+ });
2678
+ this.cleanupManagedRecord(record);
2679
+ this.markClosedInMemory(record);
2680
+ this.markClosedInRegistry(record);
2681
+ return { status: "already_closed", instanceId: record.instanceId };
1998
2682
  }
1999
2683
  record.closedAt = Date.now();
2000
- if (record.instanceId)
2001
- this.managedByInstanceId.delete(record.instanceId);
2002
- this.pending.delete(record);
2003
- cleanupManagedBaseplateFiles(record);
2684
+ this.cleanupManagedRecord(record);
2685
+ this.markClosedInMemory(record);
2686
+ this.markClosedInRegistry(record);
2687
+ return { status: "closed", instanceId: record.instanceId };
2004
2688
  }
2005
2689
  closeConnectedInstance(instance) {
2006
2690
  const process2 = this.findProcessForConnectedInstance(instance);
@@ -2010,6 +2694,10 @@ var init_studio_instance_manager = __esm({
2010
2694
  this.closeProcess(process2.Id);
2011
2695
  }
2012
2696
  closeProcess(processId) {
2697
+ if (this.processAdapter.stopProcess) {
2698
+ this.processAdapter.stopProcess(processId);
2699
+ return;
2700
+ }
2013
2701
  if (process.platform === "win32" || isWsl()) {
2014
2702
  powershell(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
2015
2703
  } else {
@@ -2022,7 +2710,7 @@ var init_studio_instance_manager = __esm({
2022
2710
  }
2023
2711
  }
2024
2712
  findProcessForConnectedInstance(instance) {
2025
- const processes = listStudioProcesses();
2713
+ const processes = this.listStudioProcesses();
2026
2714
  if (processes.length === 0)
2027
2715
  return void 0;
2028
2716
  if (processes.length === 1)
@@ -2041,13 +2729,119 @@ var init_studio_instance_manager = __esm({
2041
2729
  }
2042
2730
  return void 0;
2043
2731
  }
2732
+ listStudioProcesses() {
2733
+ return this.processAdapter.listStudioProcesses?.() ?? listStudioProcesses();
2734
+ }
2735
+ getCurrentBootId() {
2736
+ return this.processAdapter.currentBootId?.() ?? currentBootId();
2737
+ }
2738
+ registrySweepOptions() {
2739
+ return {
2740
+ currentBootId: this.getCurrentBootId(),
2741
+ isProcessRunning: (record) => this.isRegistryProcessRunning(record),
2742
+ cleanupRecord: (record) => this.cleanupManagedRecord(record)
2743
+ };
2744
+ }
2745
+ sweepRegistry() {
2746
+ this.registry.sweep(this.registrySweepOptions());
2747
+ }
2748
+ findProcessById(processId) {
2749
+ return this.listStudioProcesses().find((proc) => proc.Id === processId);
2750
+ }
2751
+ isRegistryProcessRunning(record) {
2752
+ const processId = record.nativeProcessId ?? record.spawnPid;
2753
+ if (!processId)
2754
+ return true;
2755
+ const studioProcess = this.findProcessById(processId);
2756
+ return !!studioProcess && this.verifyProcessForRecord(this.fromRegistryRecord(record), studioProcess);
2757
+ }
2758
+ verifyProcessForRecord(record, studioProcess) {
2759
+ const processName = `${studioProcess.Name ?? ""} ${studioProcess.Path ?? ""}`.toLowerCase();
2760
+ if (!processName.includes("robloxstudio"))
2761
+ return false;
2762
+ const processId = record.nativeProcessId ?? record.spawnPid;
2763
+ if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
2764
+ return true;
2765
+ const processPath = studioProcess.Path ? path2.normalize(studioProcess.Path).toLowerCase() : "";
2766
+ const exePath = record.exe ? path2.normalize(record.exe).toLowerCase() : "";
2767
+ if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
2768
+ return true;
2769
+ }
2770
+ const commandLine = studioProcess.CommandLine ?? "";
2771
+ if (record.localPlaceFile && commandLine.includes(path2.basename(record.localPlaceFile)))
2772
+ return true;
2773
+ if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
2774
+ return true;
2775
+ return false;
2776
+ }
2777
+ cleanupManagedRecord(record) {
2778
+ if (record.source !== "baseplate")
2779
+ return;
2780
+ cleanupManagedBaseplateFiles({ source: "baseplate", localPlaceFile: record.localPlaceFile });
2781
+ }
2782
+ markClosedInMemory(record) {
2783
+ record.closedAt = record.closedAt ?? Date.now();
2784
+ if (record.instanceId)
2785
+ this.managedByInstanceId.delete(record.instanceId);
2786
+ this.pending.delete(record);
2787
+ }
2788
+ markClosedInRegistry(record) {
2789
+ if (record.recordId)
2790
+ this.registry.markClosed(record.recordId, record.closedAt ?? Date.now());
2791
+ }
2792
+ toRegistryRecord(record) {
2793
+ if (!record.recordId)
2794
+ throw new Error("Managed Studio record is missing recordId.");
2795
+ if (!record.bootId)
2796
+ throw new Error("Managed Studio record is missing bootId.");
2797
+ return {
2798
+ version: 1,
2799
+ recordId: record.recordId,
2800
+ instanceId: record.instanceId,
2801
+ source: record.source,
2802
+ nativeProcessId: record.nativeProcessId,
2803
+ spawnPid: record.spawnPid,
2804
+ exe: record.exe,
2805
+ args: record.args,
2806
+ placeId: record.placeId,
2807
+ universeId: record.universeId,
2808
+ placeVersion: record.placeVersion,
2809
+ localPlaceFile: record.localPlaceFile,
2810
+ deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose,
2811
+ launchedAt: record.launchedAt,
2812
+ attachedAt: record.instanceId ? Date.now() : void 0,
2813
+ closedAt: record.closedAt,
2814
+ ownerPid: record.ownerPid,
2815
+ bootId: record.bootId
2816
+ };
2817
+ }
2818
+ fromRegistryRecord(record) {
2819
+ return {
2820
+ recordId: record.recordId,
2821
+ source: record.source,
2822
+ instanceId: record.instanceId,
2823
+ nativeProcessId: record.nativeProcessId,
2824
+ spawnPid: record.spawnPid,
2825
+ exe: record.exe,
2826
+ args: record.args,
2827
+ placeId: record.placeId,
2828
+ universeId: record.universeId,
2829
+ placeVersion: record.placeVersion,
2830
+ localPlaceFile: record.localPlaceFile,
2831
+ launchedAt: record.launchedAt,
2832
+ closedAt: record.closedAt,
2833
+ ownerPid: record.ownerPid,
2834
+ bootId: record.bootId,
2835
+ deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose
2836
+ };
2837
+ }
2044
2838
  };
2045
2839
  }
2046
2840
  });
2047
2841
 
2048
2842
  // ../core/dist/image-decode.js
2049
- import * as fs from "fs";
2050
- import * as path2 from "path";
2843
+ import * as fs2 from "fs";
2844
+ import * as path3 from "path";
2051
2845
  import { inflateSync } from "zlib";
2052
2846
  function paethPredictor(a, b, c) {
2053
2847
  const p = a + b - c;
@@ -2205,11 +2999,11 @@ function decodePngToRgba(data) {
2205
2999
  return resizeRgbaNearest({ width, height, rgba });
2206
3000
  }
2207
3001
  function decodeImagePathToRgba(imagePath) {
2208
- const resolved = path2.resolve(imagePath);
2209
- if (!fs.existsSync(resolved)) {
3002
+ const resolved = path3.resolve(imagePath);
3003
+ if (!fs2.existsSync(resolved)) {
2210
3004
  throw new Error(`image_path not found: ${imagePath}`);
2211
3005
  }
2212
- return decodePngToRgba(fs.readFileSync(resolved));
3006
+ return decodePngToRgba(fs2.readFileSync(resolved));
2213
3007
  }
2214
3008
  var PNG_SIGNATURE, MAX_EDITABLE_IMAGE_DIMENSION;
2215
3009
  var init_image_decode = __esm({
@@ -3237,9 +4031,19 @@ var init_png_encoder = __esm({
3237
4031
  });
3238
4032
 
3239
4033
  // ../core/dist/tools/index.js
3240
- import * as fs2 from "fs";
3241
- import * as os2 from "os";
3242
- import * as path3 from "path";
4034
+ import * as fs3 from "fs";
4035
+ import * as os3 from "os";
4036
+ import * as path4 from "path";
4037
+ function multiplayerStopDisabledBody() {
4038
+ return {
4039
+ success: false,
4040
+ error: "multiplayer_stop_disabled",
4041
+ message: MULTIPLAYER_STOP_DISABLED_MESSAGE,
4042
+ reason: "StudioTestService:EndTest does not reliably end StudioTestService multiplayer sessions from MCP right now.",
4043
+ manualCleanupRequired: true,
4044
+ recoveryHint: "Close the Roblox Studio multiplayer test windows manually."
4045
+ };
4046
+ }
3243
4047
  function encodeImageFromRgbaResponse(response, format, quality) {
3244
4048
  if (!response.data || response.width === void 0 || response.height === void 0) {
3245
4049
  throw new Error("Render response missing data, width, or height");
@@ -3316,8 +4120,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
3316
4120
  if (typeof sourcePath !== "string" || sourcePath === "") {
3317
4121
  throw new Error("baseline_path must be a non-empty string when provided");
3318
4122
  }
3319
- const resolved = path3.resolve(sourcePath);
3320
- const parsed = JSON.parse(fs2.readFileSync(resolved, "utf8"));
4123
+ const resolved = path4.resolve(sourcePath);
4124
+ const parsed = JSON.parse(fs3.readFileSync(resolved, "utf8"));
3321
4125
  const record = asRecord(parsed);
3322
4126
  if (!record)
3323
4127
  throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
@@ -3765,7 +4569,7 @@ end
3765
4569
  error("Unsupported device simulator operation: " .. tostring(opts.operation), 0)
3766
4570
  `.trim();
3767
4571
  }
3768
- var MAX_INLINE_IMAGE_BYTES, MAX_DEVICE_MATRIX_ENTRIES, MAX_NETWORK_PACKET_LOSS_PERCENT, STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL, NETWORK_PROFILE_KEYS, NETWORK_PROFILES, ZERO_NETWORK_PROFILE, SIMULATION_PERSISTENCE_NOTES, RobloxStudioTools;
4572
+ var MAX_INLINE_IMAGE_BYTES, MAX_DEVICE_MATRIX_ENTRIES, MAX_NETWORK_PACKET_LOSS_PERCENT, STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL, MULTIPLAYER_FORCE_REQUIRED_MESSAGE, MULTIPLAYER_STOP_DISABLED_MESSAGE, NETWORK_PROFILE_KEYS, NETWORK_PROFILES, ZERO_NETWORK_PROFILE, SIMULATION_PERSISTENCE_NOTES, RobloxStudioTools;
3769
4573
  var init_tools = __esm({
3770
4574
  "../core/dist/tools/index.js"() {
3771
4575
  "use strict";
@@ -3776,12 +4580,15 @@ var init_tools = __esm({
3776
4580
  init_roblox_cookie_client();
3777
4581
  init_studio_instance_manager();
3778
4582
  init_image_decode();
4583
+ init_roblox_docs();
3779
4584
  init_jpeg_encoder();
3780
4585
  init_png_encoder();
3781
4586
  MAX_INLINE_IMAGE_BYTES = 6e6;
3782
4587
  MAX_DEVICE_MATRIX_ENTRIES = 6;
3783
4588
  MAX_NETWORK_PACKET_LOSS_PERCENT = 0.5;
3784
4589
  STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL = "Studio Assistant Source Image";
4590
+ MULTIPLAYER_FORCE_REQUIRED_MESSAGE = "StudioTestService multiplayer stop is currently disabled because StudioTestService:EndTest is broken for this flow. Pass force=true only if you understand you must manually close the multiplayer test windows afterward.";
4591
+ MULTIPLAYER_STOP_DISABLED_MESSAGE = "Multiplayer playtest stop/end is disabled because StudioTestService:EndTest is currently broken for this flow. Manually close the Studio multiplayer test windows instead.";
3785
4592
  NETWORK_PROFILE_KEYS = [
3786
4593
  "InboundNetworkMinDelayMs",
3787
4594
  "OutboundNetworkMinDelayMs",
@@ -3845,6 +4652,17 @@ var init_tools = __esm({
3845
4652
  _textResult(body) {
3846
4653
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
3847
4654
  }
4655
+ async getRobloxDocs(name, docType, section) {
4656
+ if (!name || typeof name !== "string") {
4657
+ throw new Error('get_roblox_docs requires a name (e.g. "ProximityPrompt")');
4658
+ }
4659
+ const category = docType ?? "classes";
4660
+ if (!isDocCategory(category)) {
4661
+ throw new Error(`Invalid doc_type "${category}". Valid categories: ${DOC_CATEGORIES.join(", ")}`);
4662
+ }
4663
+ const result = await getRobloxDoc(category, name.trim(), section);
4664
+ return { content: [{ type: "text", text: result.content }] };
4665
+ }
3848
4666
  _parseTextResult(result) {
3849
4667
  const text = result?.content?.[0]?.text;
3850
4668
  if (typeof text !== "string")
@@ -4186,8 +5004,8 @@ var init_tools = __esm({
4186
5004
  timedOut: true
4187
5005
  };
4188
5006
  }
4189
- async getFileTree(path4 = "", instance_id) {
4190
- const response = await this._callSingle("/api/file-tree", { path: path4 }, void 0, instance_id);
5007
+ async getFileTree(path5 = "", instance_id) {
5008
+ const response = await this._callSingle("/api/file-tree", { path: path5 }, void 0, instance_id);
4191
5009
  return {
4192
5010
  content: [
4193
5011
  {
@@ -4304,9 +5122,9 @@ var init_tools = __esm({
4304
5122
  ]
4305
5123
  };
4306
5124
  }
4307
- async getProjectStructure(path4, maxDepth, scriptsOnly, instance_id) {
5125
+ async getProjectStructure(path5, maxDepth, scriptsOnly, instance_id) {
4308
5126
  const response = await this._callSingle("/api/project-structure", {
4309
- path: path4,
5127
+ path: path5,
4310
5128
  maxDepth,
4311
5129
  scriptsOnly
4312
5130
  }, void 0, instance_id);
@@ -4486,17 +5304,20 @@ var init_tools = __esm({
4486
5304
  const topService = typeof response.topService === "string" && response.topService.length > 0 ? response.topService : pathSegments[0] === "game" ? pathSegments[1] ?? "game" : pathSegments[0];
4487
5305
  const typeNote = scriptTypeInfo[response.className] || response.className;
4488
5306
  const serviceNote = serviceInfo[topService] || topService;
5307
+ const showRange = Boolean(response.isPartial || response.truncated) && response.startLine !== void 0 && response.endLine !== void 0;
4489
5308
  const headerLines = [
4490
5309
  `Path: ${pathStr}`,
4491
5310
  `Type: ${typeNote}`,
4492
5311
  `Location: ${serviceNote}`,
4493
- `Lines: ${response.lineCount} total${response.isPartial ? ` (showing ${response.startLine}-${response.endLine})` : ""}`
5312
+ `Lines: ${response.lineCount} total${showRange ? ` (showing ${response.startLine}-${response.endLine})` : ""}`
4494
5313
  ];
4495
5314
  if (response.enabled === false) {
4496
5315
  headerLines.push(`Status: DISABLED`);
4497
5316
  }
4498
- if (response.truncated) {
4499
- headerLines.push(`Note: Truncated to first 1000 lines, use startLine/endLine to read more`);
5317
+ if (typeof response.note === "string" && response.note.length > 0) {
5318
+ headerLines.push(`Note: ${response.note}`);
5319
+ } else if (response.truncated) {
5320
+ headerLines.push(`Note: Truncated response; use line_range to read more`);
4500
5321
  }
4501
5322
  const header = headerLines.join("\n");
4502
5323
  const code = response.numberedSource || response.source;
@@ -4574,7 +5395,7 @@ ${code}`
4574
5395
  }
4575
5396
  const response = await this._callSingle("/api/grep-scripts", {
4576
5397
  pattern,
4577
- ...options
5398
+ ...options ?? {}
4578
5399
  }, void 0, instance_id);
4579
5400
  return {
4580
5401
  content: [
@@ -5227,9 +6048,9 @@ ${code}`
5227
6048
  const rawJson = mutable.raw_json;
5228
6049
  if (typeof rawJson === "string") {
5229
6050
  if (typeof outputPath === "string" && outputPath !== "") {
5230
- const resolvedOutputPath = path3.resolve(outputPath);
5231
- fs2.mkdirSync(path3.dirname(resolvedOutputPath), { recursive: true });
5232
- fs2.writeFileSync(resolvedOutputPath, rawJson, "utf8");
6051
+ const resolvedOutputPath = path4.resolve(outputPath);
6052
+ fs3.mkdirSync(path4.dirname(resolvedOutputPath), { recursive: true });
6053
+ fs3.writeFileSync(resolvedOutputPath, rawJson, "utf8");
5233
6054
  mutable.output_path = resolvedOutputPath;
5234
6055
  }
5235
6056
  delete mutable.raw_json;
@@ -5290,9 +6111,9 @@ ${code}`
5290
6111
  const rawSnapshotBase64 = mutable.raw_snapshot_base64;
5291
6112
  if (typeof rawSnapshotBase64 === "string") {
5292
6113
  if (typeof outputPath === "string" && outputPath !== "") {
5293
- const resolvedOutputPath = path3.resolve(outputPath);
5294
- fs2.mkdirSync(path3.dirname(resolvedOutputPath), { recursive: true });
5295
- fs2.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
6114
+ const resolvedOutputPath = path4.resolve(outputPath);
6115
+ fs3.mkdirSync(path4.dirname(resolvedOutputPath), { recursive: true });
6116
+ fs3.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
5296
6117
  mutable.output_path = resolvedOutputPath;
5297
6118
  }
5298
6119
  delete mutable.raw_snapshot_base64;
@@ -5306,9 +6127,9 @@ ${code}`
5306
6127
  });
5307
6128
  }
5308
6129
  if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
5309
- const resolvedSummaryPath = path3.resolve(summaryOutputPath);
5310
- fs2.mkdirSync(path3.dirname(resolvedSummaryPath), { recursive: true });
5311
- fs2.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
6130
+ const resolvedSummaryPath = path4.resolve(summaryOutputPath);
6131
+ fs3.mkdirSync(path4.dirname(resolvedSummaryPath), { recursive: true });
6132
+ fs3.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
5312
6133
  mutable.summary_output_path = resolvedSummaryPath;
5313
6134
  }
5314
6135
  if (!includeComparisonIndex) {
@@ -5355,6 +6176,14 @@ ${code}`
5355
6176
  return void 0;
5356
6177
  return this._positiveInteger(value, name);
5357
6178
  }
6179
+ _optionalFiniteNumber(value, name) {
6180
+ if (value === void 0 || value === null)
6181
+ return void 0;
6182
+ if (typeof value !== "number" || !Number.isFinite(value)) {
6183
+ throw new Error(`${name} must be a finite number.`);
6184
+ }
6185
+ return value;
6186
+ }
5358
6187
  _publicInstanceKey(instance) {
5359
6188
  return `${instance.instanceId}:${instance.role}:${instance.connectedAt}`;
5360
6189
  }
@@ -5367,7 +6196,7 @@ ${code}`
5367
6196
  return record.placeId !== void 0 && instance.placeId === record.placeId;
5368
6197
  }
5369
6198
  if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
5370
- const expectedName = path3.basename(record.localPlaceFile);
6199
+ const expectedName = path4.basename(record.localPlaceFile);
5371
6200
  return instance.placeName === expectedName || instance.dataModelName === expectedName;
5372
6201
  }
5373
6202
  return true;
@@ -5376,11 +6205,11 @@ ${code}`
5376
6205
  const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`);
5377
6206
  if (!response.ok) {
5378
6207
  const body = await response.text().catch(() => "");
5379
- throw new Error(`Could not derive universe_id for place ${placeId} (${response.status}): ${body}`);
6208
+ throw new Error(`Could not resolve the universe for place_id ${placeId} (${response.status}): ${body}`);
5380
6209
  }
5381
6210
  const data = await response.json();
5382
6211
  if (typeof data.universeId !== "number" || !Number.isFinite(data.universeId)) {
5383
- throw new Error(`Could not derive universe_id for place ${placeId}.`);
6212
+ throw new Error(`Could not resolve the universe for place_id ${placeId}.`);
5384
6213
  }
5385
6214
  return Math.trunc(data.universeId);
5386
6215
  }
@@ -5422,7 +6251,7 @@ ${code}`
5422
6251
  });
5423
6252
  }
5424
6253
  const placeId2 = this._positiveInteger(request.place_id, "place_id");
5425
- const rawMaxPageSize = this._optionalPositiveInteger(request.max_page_size, "max_page_size") ?? 10;
6254
+ const rawMaxPageSize = Math.trunc(this._optionalFiniteNumber(request.max_page_size, "max_page_size") ?? 10);
5426
6255
  const maxPageSize = Math.max(1, Math.min(50, rawMaxPageSize));
5427
6256
  const pageToken = typeof request.page_token === "string" ? request.page_token : void 0;
5428
6257
  const response = await this.openCloudClient.listAssetVersions(placeId2, maxPageSize, pageToken);
@@ -5467,31 +6296,38 @@ ${code}`
5467
6296
  if (action === "close") {
5468
6297
  let record2;
5469
6298
  if (instance_id) {
5470
- record2 = this.instanceManager.get(instance_id);
5471
- if (!record2) {
5472
- const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
5473
- const edit = connected2.find((instance) => instance.role === "edit");
5474
- if (!edit) {
5475
- return this._textResult({
5476
- error: "Instance is not connected or managed.",
5477
- instance_id
5478
- });
5479
- }
5480
- try {
5481
- this.instanceManager.closeConnectedInstance(edit);
5482
- await sleep(500);
5483
- } catch (error) {
5484
- return this._textResult({
5485
- error: error instanceof Error ? error.message : String(error),
5486
- instance_id
5487
- });
5488
- }
5489
- this.bridge.unregisterInstanceId(instance_id);
6299
+ const managedClose = this.instanceManager.closeByInstanceId(instance_id);
6300
+ if (managedClose.status !== "not_found") {
6301
+ await this.bridge.unregisterInstanceIdEverywhere(instance_id);
6302
+ await sleep(500);
6303
+ await this.bridge.unregisterInstanceIdEverywhere(instance_id);
5490
6304
  return this._textResult({
5491
6305
  instance_id,
5492
- message: "Studio instance closed."
6306
+ message: managedClose.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
5493
6307
  });
5494
6308
  }
6309
+ const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
6310
+ const edit = connected2.find((instance) => instance.role === "edit");
6311
+ if (!edit) {
6312
+ return this._textResult({
6313
+ error: "Instance is not connected or managed.",
6314
+ instance_id
6315
+ });
6316
+ }
6317
+ try {
6318
+ this.instanceManager.closeConnectedInstance(edit);
6319
+ await sleep(500);
6320
+ } catch (error) {
6321
+ return this._textResult({
6322
+ error: error instanceof Error ? error.message : String(error),
6323
+ instance_id
6324
+ });
6325
+ }
6326
+ await this.bridge.unregisterInstanceIdEverywhere(instance_id);
6327
+ return this._textResult({
6328
+ instance_id,
6329
+ message: "Studio instance closed."
6330
+ });
5495
6331
  } else {
5496
6332
  const active = this.instanceManager.list().filter((entry) => entry.closedAt === void 0);
5497
6333
  if (active.length === 0) {
@@ -5506,15 +6342,15 @@ ${code}`
5506
6342
  record2 = active[0];
5507
6343
  }
5508
6344
  if (record2.instanceId)
5509
- this.bridge.unregisterInstanceId(record2.instanceId);
5510
- this.instanceManager.close(record2);
6345
+ await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
6346
+ const closeResult = this.instanceManager.close(record2);
5511
6347
  if (record2.instanceId) {
5512
6348
  await sleep(500);
5513
- this.bridge.unregisterInstanceId(record2.instanceId);
6349
+ await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
5514
6350
  }
5515
6351
  return this._textResult({
5516
6352
  instance_id: record2.instanceId,
5517
- message: "Studio instance closed."
6353
+ message: closeResult.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
5518
6354
  });
5519
6355
  }
5520
6356
  const source = request.source;
@@ -5522,8 +6358,8 @@ ${code}`
5522
6358
  throw new Error("manage_instance action=launch requires source=baseplate|local_file|published_place|place_revision");
5523
6359
  }
5524
6360
  const launchSource = source;
5525
- const placeId = launchSource === "published_place" || launchSource === "place_revision" ? this._positiveInteger(request.place_id, "place_id") : this._optionalPositiveInteger(request.place_id, "place_id");
5526
- const placeVersion = launchSource === "place_revision" ? this._positiveInteger(request.place_version, "place_version") : this._optionalPositiveInteger(request.place_version, "place_version");
6361
+ const placeId = launchSource === "published_place" || launchSource === "place_revision" ? this._positiveInteger(request.place_id, "place_id") : void 0;
6362
+ const placeVersion = launchSource === "place_revision" ? this._positiveInteger(request.place_version, "place_version") : void 0;
5527
6363
  const localPlaceFile = typeof request.local_place_file === "string" ? request.local_place_file : void 0;
5528
6364
  if (launchSource === "published_place" && placeId !== void 0 && this._isLatestPublishedPlaceOpen(placeId)) {
5529
6365
  return this._textResult({
@@ -5531,7 +6367,7 @@ ${code}`
5531
6367
  message: `place_id ${placeId} is already connected. Use the existing instance or launch a specific place_revision.`
5532
6368
  });
5533
6369
  }
5534
- const universeId = launchSource === "published_place" || launchSource === "place_revision" ? this._optionalPositiveInteger(request.universe_id, "universe_id") ?? await this._deriveUniverseId(placeId) : this._optionalPositiveInteger(request.universe_id, "universe_id");
6370
+ const universeId = launchSource === "published_place" || launchSource === "place_revision" ? await this._deriveUniverseId(placeId) : void 0;
5535
6371
  const waitForConnection = request.wait_for_connection !== false;
5536
6372
  const timeoutMs = this._optionalPositiveInteger(request.timeout_ms, "timeout_ms") ?? 12e4;
5537
6373
  const beforeKeys = new Set(this.bridge.getPublicInstances().map((instance) => this._publicInstanceKey(instance)));
@@ -5797,26 +6633,40 @@ ${code}`
5797
6633
  }
5798
6634
  return hasServer && clientCount >= 2;
5799
6635
  }
5800
- async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
6636
+ async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30, connectedAfter) {
5801
6637
  const deadline = Date.now() + timeoutSec * 1e3;
6638
+ let lastPhase;
5802
6639
  while (Date.now() < deadline) {
5803
6640
  const exact = await this._waitForExactClientCount(instanceId, clientCount, 0.25, 0);
5804
6641
  if (exact.ok || exact.extraClients) {
6642
+ if (exact.ok && connectedAfter !== void 0) {
6643
+ const instances = this.bridge.getInstances().filter((inst) => inst.instanceId === instanceId);
6644
+ const freshRoles = new Set(instances.filter((inst) => inst.connectedAt >= connectedAfter).map((inst) => inst.role));
6645
+ const freshClientCount = [...freshRoles].filter((role) => /^client-\d+$/.test(role)).length;
6646
+ if (!freshRoles.has("server") || freshClientCount !== clientCount) {
6647
+ await sleep(250);
6648
+ continue;
6649
+ }
6650
+ }
5805
6651
  return { ok: exact.ok, roles: exact.roles, timedOut: false, error: exact.extraClients ? `Expected ${clientCount} client(s), but Studio registered ${exact.clientCount}.` : void 0 };
5806
6652
  }
5807
6653
  try {
5808
- const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
6654
+ const remainingMs = Math.max(1, Math.min(1e3, deadline - Date.now()));
6655
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit", remainingMs);
5809
6656
  const session = editState?.session;
5810
- if (session?.phase === "failed" || session?.phase === "completed") {
6657
+ if (typeof session?.phase === "string") {
6658
+ lastPhase = session.phase;
6659
+ }
6660
+ if (session?.phase === "failed") {
5811
6661
  return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: false, phase: session.phase, error: session.error };
5812
6662
  }
5813
6663
  } catch {
5814
6664
  }
5815
6665
  await sleep(250);
5816
6666
  }
5817
- return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
6667
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true, phase: lastPhase };
5818
6668
  }
5819
- async multiplayerPlaytest(action, numPlayers, target, testArgs, value, timeout, instance_id) {
6669
+ async multiplayerPlaytest(action, numPlayers, target, testArgs, value, timeout, instance_id, force) {
5820
6670
  if (action !== "start" && action !== "status" && action !== "add_players" && action !== "leave_client" && action !== "end") {
5821
6671
  throw new Error("multiplayer_playtest requires action=start|status|add_players|leave_client|end");
5822
6672
  }
@@ -5838,93 +6688,130 @@ ${code}`
5838
6688
  });
5839
6689
  }
5840
6690
  if (action === "start") {
5841
- const body2 = this._parseTextResult(await this.multiplayerTestStart(numPlayers, testArgs, timeout, instance_id));
5842
- const state = body2.state && typeof body2.state === "object" ? body2.state : {};
5843
- const success = body2.success === true && body2.ready === true;
5844
- return this._textResult(success ? {
6691
+ if (force !== true) {
6692
+ return this._textResult({
6693
+ success: false,
6694
+ action,
6695
+ error: "multiplayer_force_required",
6696
+ message: MULTIPLAYER_FORCE_REQUIRED_MESSAGE,
6697
+ requiresForce: true,
6698
+ manualCleanupRequired: true
6699
+ });
6700
+ }
6701
+ const body = this._parseTextResult(await this.multiplayerTestStart(numPlayers, testArgs, timeout, instance_id, force));
6702
+ const state = body.state && typeof body.state === "object" ? body.state : {};
6703
+ const launched = body.success === true && body.ready === true;
6704
+ return this._textResult(launched ? {
5845
6705
  success: true,
5846
6706
  action,
5847
- message: "Multiplayer playtest started.",
5848
- roles: Array.isArray(body2.roles) ? body2.roles : void 0,
6707
+ message: "Multiplayer playtest started. Stop/end is disabled; close the multiplayer test windows manually when finished.",
6708
+ ready: true,
6709
+ manualCleanupRequired: true,
6710
+ roles: Array.isArray(body.roles) ? body.roles : void 0,
5849
6711
  clientRoles: Array.isArray(state.clientRoles) ? state.clientRoles : void 0,
5850
6712
  playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
5851
6713
  } : {
5852
6714
  success: false,
5853
6715
  action,
5854
- error: body2.error ?? body2.wait?.error ?? "start_failed",
5855
- message: body2.success === true ? "Multiplayer playtest did not become ready before timeout." : body2.message ?? "Multiplayer playtest did not start.",
5856
- roles: Array.isArray(body2.roles) ? body2.roles : void 0
6716
+ error: body.error ?? body.wait?.error ?? "multiplayer_start_not_detected",
6717
+ message: body.success === true ? "Multiplayer playtest start was requested, but MCP did not detect the required server/client peers before timeout. You may need to close the test windows manually." : body.message ?? "Multiplayer playtest did not start.",
6718
+ manualCleanupRequired: body.startRequested === true || body.launched === true ? true : void 0,
6719
+ roles: Array.isArray(body.roles) ? body.roles : void 0
5857
6720
  });
5858
6721
  }
5859
6722
  if (action === "add_players") {
5860
- const body2 = this._parseTextResult(await this.multiplayerTestAddPlayers(numPlayers, timeout, instance_id));
5861
- const state = body2.state && typeof body2.state === "object" ? body2.state : {};
5862
- const success = body2.success === true && body2.ready === true;
6723
+ const body = this._parseTextResult(await this.multiplayerTestAddPlayers(numPlayers, timeout, instance_id));
6724
+ const state = body.state && typeof body.state === "object" ? body.state : {};
6725
+ const success = body.success === true && body.ready === true;
5863
6726
  return this._textResult(success ? {
5864
6727
  success: true,
5865
6728
  action,
5866
6729
  message: "Players added.",
5867
- roles: Array.isArray(body2.roles) ? body2.roles : void 0,
6730
+ roles: Array.isArray(body.roles) ? body.roles : void 0,
5868
6731
  clientRoles: Array.isArray(state.clientRoles) ? state.clientRoles : void 0,
5869
6732
  playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
5870
6733
  } : {
5871
6734
  success: false,
5872
6735
  action,
5873
- error: body2.error ?? "add_players_failed",
5874
- message: body2.success === true ? "Players did not finish joining before timeout." : body2.message ?? "Players were not added.",
5875
- roles: Array.isArray(body2.roles) ? body2.roles : void 0
6736
+ error: body.error ?? "add_players_failed",
6737
+ message: body.success === true ? "Players did not finish joining before timeout." : body.message ?? "Players were not added.",
6738
+ roles: Array.isArray(body.roles) ? body.roles : void 0
5876
6739
  });
5877
6740
  }
5878
6741
  if (action === "leave_client") {
5879
- const body2 = this._parseTextResult(await this.multiplayerTestLeaveClient(target ?? "client-1", timeout, instance_id));
5880
- return this._textResult(body2.success === true && body2.left === true ? {
6742
+ const body = this._parseTextResult(await this.multiplayerTestLeaveClient(target ?? "client-1", timeout, instance_id));
6743
+ return this._textResult(body.success === true && body.left === true ? {
5881
6744
  success: true,
5882
6745
  action,
5883
6746
  message: "Client left.",
5884
- roles: Array.isArray(body2.roles) ? body2.roles : void 0
6747
+ roles: Array.isArray(body.roles) ? body.roles : void 0
5885
6748
  } : {
5886
6749
  success: false,
5887
6750
  action,
5888
- error: body2.error ?? "leave_client_failed",
5889
- message: body2.message ?? "Client did not leave.",
5890
- roles: Array.isArray(body2.roles) ? body2.roles : void 0
6751
+ error: body.error ?? "leave_client_failed",
6752
+ message: body.message ?? "Client did not leave.",
6753
+ roles: Array.isArray(body.roles) ? body.roles : void 0
5891
6754
  });
5892
6755
  }
5893
- const body = this._parseTextResult(await this.multiplayerTestEnd(value, timeout, instance_id));
5894
- return this._textResult(body.success === true && body.ended === true ? {
5895
- success: true,
5896
- action,
5897
- message: "Multiplayer playtest ended."
5898
- } : {
5899
- success: false,
6756
+ return this._textResult({
5900
6757
  action,
5901
- error: body.error ?? "end_failed",
5902
- message: body.message ?? "Multiplayer playtest did not end.",
5903
- roles: Array.isArray(body.roles) ? body.roles : void 0,
5904
- editDone: body.editDone === false ? false : void 0
6758
+ ...multiplayerStopDisabledBody()
5905
6759
  });
5906
6760
  }
5907
- async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id) {
6761
+ async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id, force) {
5908
6762
  if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
5909
6763
  throw new Error("numPlayers must be an integer from 1 to 8");
5910
6764
  }
5911
6765
  const editTarget = this._resolveSingleTarget("edit", instance_id);
6766
+ if (force !== true) {
6767
+ return this._textResult({
6768
+ success: false,
6769
+ error: "multiplayer_force_required",
6770
+ message: MULTIPLAYER_FORCE_REQUIRED_MESSAGE,
6771
+ requiresForce: true,
6772
+ manualCleanupRequired: true
6773
+ });
6774
+ }
6775
+ const existingRuntime = this._runtimeTargetsForEquivalentInstances(editTarget.instanceId);
6776
+ if (existingRuntime.length > 0) {
6777
+ const roles = this._rolesForEquivalentInstances(editTarget.instanceId);
6778
+ return this._textResult({
6779
+ success: false,
6780
+ error: "Multiplayer playtest already running.",
6781
+ message: "A Studio runtime is already connected for this place. Close the existing playtest windows manually before starting another multiplayer playtest.",
6782
+ ready: true,
6783
+ timedOut: false,
6784
+ roles,
6785
+ runtimeRoles: existingRuntime.map((target) => target.role),
6786
+ manualCleanupRequired: true
6787
+ });
6788
+ }
6789
+ const startedAt = Date.now();
5912
6790
  const response = await this.client.request("/api/multiplayer-test-start", { numPlayers, testArgs: testArgs ?? {} }, editTarget.instanceId, editTarget.role);
5913
6791
  if (response?.error) {
5914
6792
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
5915
6793
  }
5916
- const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ?? 30);
6794
+ const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ?? 60, startedAt);
6795
+ const launched = wait.ok;
5917
6796
  const state = await this._buildMultiplayerState(editTarget.instanceId);
6797
+ const success = response.success === true && wait.ok;
5918
6798
  return {
5919
6799
  content: [{
5920
6800
  type: "text",
5921
6801
  text: JSON.stringify({
5922
6802
  ...response,
6803
+ success,
5923
6804
  ready: wait.ok,
6805
+ launched,
6806
+ startRequested: response.success === true,
5924
6807
  timedOut: wait.timedOut,
5925
6808
  wait,
5926
6809
  roles: wait.roles,
5927
- state
6810
+ state,
6811
+ error: success ? void 0 : wait.error ?? "multiplayer_start_not_detected",
6812
+ message: success ? "Multiplayer Studio test started and runtime peers detected." : "Multiplayer Studio test start was requested, but MCP did not detect the required server/client peers before timeout. Close the multiplayer test windows manually if Studio launched them.",
6813
+ manualCleanupRequired: success || response.success === true ? true : void 0,
6814
+ startedAt
5928
6815
  })
5929
6816
  }]
5930
6817
  };
@@ -5985,27 +6872,10 @@ ${code}`
5985
6872
  };
5986
6873
  }
5987
6874
  async multiplayerTestEnd(value, timeout, instance_id) {
5988
- const serverTarget = this._resolveSingleTarget("server", instance_id);
5989
- const response = await this.client.request("/api/multiplayer-test-end", { value: value ?? "ended_by_mcp" }, serverTarget.instanceId, serverTarget.role);
5990
- if (response?.error) {
5991
- return { content: [{ type: "text", text: JSON.stringify(response) }] };
5992
- }
5993
- const editDone = await this._waitForMultiplayerEditDone(serverTarget.instanceId, timeout ?? 30);
5994
- const wait = await this._waitForRuntimeRoles(serverTarget.instanceId, { noRuntime: true }, timeout ?? 30);
5995
- const state = await this._buildMultiplayerState(serverTarget.instanceId);
5996
- return {
5997
- content: [{
5998
- type: "text",
5999
- text: JSON.stringify({
6000
- ...response,
6001
- ended: wait.ok,
6002
- editDone,
6003
- timedOut: wait.timedOut,
6004
- roles: wait.roles,
6005
- state
6006
- })
6007
- }]
6008
- };
6875
+ void value;
6876
+ void timeout;
6877
+ void instance_id;
6878
+ return this._textResult(multiplayerStopDisabledBody());
6009
6879
  }
6010
6880
  async getConnectedInstances() {
6011
6881
  const instances = this.bridge.getPublicInstances();
@@ -6041,14 +6911,14 @@ ${code}`
6041
6911
  };
6042
6912
  }
6043
6913
  static findProjectRoot(startDir) {
6044
- let dir = path3.resolve(startDir);
6914
+ let dir = path4.resolve(startDir);
6045
6915
  let previous = "";
6046
6916
  while (dir !== previous) {
6047
- if (fs2.existsSync(path3.join(dir, ".git")) || fs2.existsSync(path3.join(dir, "package.json"))) {
6917
+ if (fs3.existsSync(path4.join(dir, ".git")) || fs3.existsSync(path4.join(dir, "package.json"))) {
6048
6918
  return dir;
6049
6919
  }
6050
6920
  previous = dir;
6051
- dir = path3.dirname(dir);
6921
+ dir = path4.dirname(dir);
6052
6922
  }
6053
6923
  return null;
6054
6924
  }
@@ -6056,15 +6926,15 @@ ${code}`
6056
6926
  if (!candidate)
6057
6927
  return false;
6058
6928
  try {
6059
- return fs2.statSync(candidate).isDirectory();
6929
+ return fs3.statSync(candidate).isDirectory();
6060
6930
  } catch {
6061
6931
  return false;
6062
6932
  }
6063
6933
  }
6064
6934
  static ensureWritableDirectory(candidate, label) {
6065
- const resolved = path3.resolve(candidate);
6935
+ const resolved = path4.resolve(candidate);
6066
6936
  try {
6067
- fs2.mkdirSync(resolved, { recursive: true });
6937
+ fs3.mkdirSync(resolved, { recursive: true });
6068
6938
  } catch (error) {
6069
6939
  throw new Error(`Unable to create ${label} build-library directory at ${resolved}: ${error.message}`);
6070
6940
  }
@@ -6072,7 +6942,7 @@ ${code}`
6072
6942
  throw new Error(`${label} build-library path is not a directory: ${resolved}`);
6073
6943
  }
6074
6944
  try {
6075
- fs2.accessSync(resolved, fs2.constants.W_OK);
6945
+ fs3.accessSync(resolved, fs3.constants.W_OK);
6076
6946
  } catch (error) {
6077
6947
  throw new Error(`${label} build-library directory is not writable: ${resolved}. ${error.message}`);
6078
6948
  }
@@ -6083,25 +6953,25 @@ ${code}`
6083
6953
  if (_RobloxStudioTools._cachedLibraryPath)
6084
6954
  return _RobloxStudioTools._cachedLibraryPath;
6085
6955
  const overridePath = process.env.ROBLOXSTUDIO_MCP_BUILD_LIBRARY || process.env.BUILD_LIBRARY_PATH;
6086
- const cwd = path3.resolve(process.cwd());
6956
+ const cwd = path4.resolve(process.cwd());
6087
6957
  const projectRoot = _RobloxStudioTools.findProjectRoot(cwd);
6088
- const homeLibraryPath = path3.join(os2.homedir(), ".robloxstudio-mcp", "build-library");
6089
- const projectLibraryPath = projectRoot ? path3.join(projectRoot, "build-library") : null;
6090
- const cwdLibraryPath = path3.join(cwd, "build-library");
6958
+ const homeLibraryPath = path4.join(os3.homedir(), ".robloxstudio-mcp", "build-library");
6959
+ const projectLibraryPath = projectRoot ? path4.join(projectRoot, "build-library") : null;
6960
+ const cwdLibraryPath = path4.join(cwd, "build-library");
6091
6961
  let result;
6092
6962
  if (overridePath) {
6093
6963
  result = _RobloxStudioTools.ensureWritableDirectory(overridePath, "override");
6094
6964
  } else {
6095
6965
  const existing = [projectLibraryPath, cwdLibraryPath].find((c) => c && _RobloxStudioTools.isDirectory(c) && (() => {
6096
6966
  try {
6097
- fs2.accessSync(c, fs2.constants.W_OK);
6967
+ fs3.accessSync(c, fs3.constants.W_OK);
6098
6968
  return true;
6099
6969
  } catch {
6100
6970
  return false;
6101
6971
  }
6102
6972
  })());
6103
6973
  if (existing) {
6104
- result = path3.resolve(existing);
6974
+ result = path4.resolve(existing);
6105
6975
  } else if (projectLibraryPath) {
6106
6976
  try {
6107
6977
  result = _RobloxStudioTools.ensureWritableDirectory(projectLibraryPath, "project-root");
@@ -6128,12 +6998,12 @@ ${code}`
6128
6998
  if (response && response.success && response.buildData) {
6129
6999
  const buildData = response.buildData;
6130
7000
  const buildId = buildData.id || `${style}/exported`;
6131
- const filePath = path3.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
6132
- const dirPath = path3.dirname(filePath);
6133
- if (!fs2.existsSync(dirPath)) {
6134
- fs2.mkdirSync(dirPath, { recursive: true });
7001
+ const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
7002
+ const dirPath = path4.dirname(filePath);
7003
+ if (!fs3.existsSync(dirPath)) {
7004
+ fs3.mkdirSync(dirPath, { recursive: true });
6135
7005
  }
6136
- fs2.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
7006
+ fs3.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
6137
7007
  response.savedTo = filePath;
6138
7008
  }
6139
7009
  return {
@@ -6230,12 +7100,12 @@ ${code}`
6230
7100
  const normalizedParts = this.normalizeBuildParts(parts, new Set(Object.keys(normalizedPalette)));
6231
7101
  const computedBounds = bounds || this.computeBounds(normalizedParts);
6232
7102
  const buildData = { id, style, bounds: computedBounds, palette: normalizedPalette, parts: normalizedParts };
6233
- const filePath = path3.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
6234
- const dirPath = path3.dirname(filePath);
6235
- if (!fs2.existsSync(dirPath)) {
6236
- fs2.mkdirSync(dirPath, { recursive: true });
7103
+ const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
7104
+ const dirPath = path4.dirname(filePath);
7105
+ if (!fs3.existsSync(dirPath)) {
7106
+ fs3.mkdirSync(dirPath, { recursive: true });
6237
7107
  }
6238
- fs2.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
7108
+ fs3.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
6239
7109
  return {
6240
7110
  content: [
6241
7111
  {
@@ -6289,12 +7159,12 @@ ${code}`
6289
7159
  };
6290
7160
  if (seed !== void 0)
6291
7161
  buildData.generatorSeed = seed;
6292
- const filePath = path3.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
6293
- const dirPath = path3.dirname(filePath);
6294
- if (!fs2.existsSync(dirPath)) {
6295
- fs2.mkdirSync(dirPath, { recursive: true });
7162
+ const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
7163
+ const dirPath = path4.dirname(filePath);
7164
+ if (!fs3.existsSync(dirPath)) {
7165
+ fs3.mkdirSync(dirPath, { recursive: true });
6296
7166
  }
6297
- fs2.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
7167
+ fs3.writeFileSync(filePath, JSON.stringify(buildData, null, 2));
6298
7168
  return {
6299
7169
  content: [
6300
7170
  {
@@ -6318,17 +7188,17 @@ ${code}`
6318
7188
  }
6319
7189
  let resolved;
6320
7190
  if (typeof buildData === "string") {
6321
- const filePath = path3.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
6322
- if (!fs2.existsSync(filePath)) {
7191
+ const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
7192
+ if (!fs3.existsSync(filePath)) {
6323
7193
  throw new Error(`Build not found in library: ${buildData}`);
6324
7194
  }
6325
- resolved = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
7195
+ resolved = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
6326
7196
  } else if (buildData.id && !buildData.parts) {
6327
- const filePath = path3.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
6328
- if (!fs2.existsSync(filePath)) {
7197
+ const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
7198
+ if (!fs3.existsSync(filePath)) {
6329
7199
  throw new Error(`Build not found in library: ${buildData.id}`);
6330
7200
  }
6331
- resolved = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
7201
+ resolved = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
6332
7202
  } else {
6333
7203
  resolved = buildData;
6334
7204
  }
@@ -6351,13 +7221,13 @@ ${code}`
6351
7221
  const styles = style ? [style] : ["medieval", "modern", "nature", "scifi", "misc"];
6352
7222
  const builds = [];
6353
7223
  for (const s of styles) {
6354
- const dirPath = path3.join(libraryPath, s);
6355
- if (!fs2.existsSync(dirPath))
7224
+ const dirPath = path4.join(libraryPath, s);
7225
+ if (!fs3.existsSync(dirPath))
6356
7226
  continue;
6357
- const files = fs2.readdirSync(dirPath).filter((f) => f.endsWith(".json"));
7227
+ const files = fs3.readdirSync(dirPath).filter((f) => f.endsWith(".json"));
6358
7228
  for (const file of files) {
6359
7229
  try {
6360
- const content = fs2.readFileSync(path3.join(dirPath, file), "utf-8");
7230
+ const content = fs3.readFileSync(path4.join(dirPath, file), "utf-8");
6361
7231
  const data = JSON.parse(content);
6362
7232
  builds.push({
6363
7233
  id: data.id || `${s}/${file.replace(".json", "")}`,
@@ -6396,11 +7266,11 @@ ${code}`
6396
7266
  if (!id) {
6397
7267
  throw new Error("Build ID is required for get_build");
6398
7268
  }
6399
- const filePath = path3.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
6400
- if (!fs2.existsSync(filePath)) {
7269
+ const filePath = path4.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
7270
+ if (!fs3.existsSync(filePath)) {
6401
7271
  throw new Error(`Build not found in library: ${id}`);
6402
7272
  }
6403
- const data = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
7273
+ const data = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
6404
7274
  const result = {
6405
7275
  id: data.id,
6406
7276
  style: data.style,
@@ -6483,11 +7353,11 @@ ${code}`
6483
7353
  if (!buildId) {
6484
7354
  throw new Error(`Invalid ${validatedKeyPath}: model key "${modelKey}" is not defined in sceneData.models`);
6485
7355
  }
6486
- const filePath = path3.join(libraryPath, `${buildId}.json`);
6487
- if (!fs2.existsSync(filePath)) {
7356
+ const filePath = path4.join(libraryPath, `${buildId}.json`);
7357
+ if (!fs3.existsSync(filePath)) {
6488
7358
  throw new Error(`Build not found in library: ${buildId}`);
6489
7359
  }
6490
- const content = fs2.readFileSync(filePath, "utf-8");
7360
+ const content = fs3.readFileSync(filePath, "utf-8");
6491
7361
  const buildData = JSON.parse(content);
6492
7362
  const buildName = buildId.split("/").pop() || buildId;
6493
7363
  expandedBuilds.push({
@@ -6827,11 +7697,11 @@ ${code}`
6827
7697
  return this.resolveUploadedReferenceImageId(decalId, instance_id);
6828
7698
  }
6829
7699
  async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
6830
- if (!fs2.existsSync(filePath)) {
7700
+ if (!fs3.existsSync(filePath)) {
6831
7701
  throw new Error(`File not found: ${filePath}`);
6832
7702
  }
6833
- const fileContent = fs2.readFileSync(filePath);
6834
- const fileName = path3.basename(filePath);
7703
+ const fileContent = fs3.readFileSync(filePath);
7704
+ const fileName = path4.basename(filePath);
6835
7705
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
6836
7706
  const result2 = await this.cookieClient.uploadDecal(fileContent, displayName, description || "");
6837
7707
  return {
@@ -7056,10 +7926,10 @@ ${code}`
7056
7926
  return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
7057
7927
  }
7058
7928
  const bytes = Buffer.from(response.base64, "base64");
7059
- const resolved = path3.resolve(outputPath);
7929
+ const resolved = path4.resolve(outputPath);
7060
7930
  try {
7061
- fs2.mkdirSync(path3.dirname(resolved), { recursive: true });
7062
- fs2.writeFileSync(resolved, bytes);
7931
+ fs3.mkdirSync(path4.dirname(resolved), { recursive: true });
7932
+ fs3.writeFileSync(resolved, bytes);
7063
7933
  } catch (err) {
7064
7934
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err.message}` }) }] };
7065
7935
  }
@@ -7092,9 +7962,9 @@ ${code}`
7092
7962
  let bytes;
7093
7963
  let sourceLabel;
7094
7964
  if (source.path !== void 0) {
7095
- const resolved = path3.resolve(source.path);
7965
+ const resolved = path4.resolve(source.path);
7096
7966
  try {
7097
- bytes = fs2.readFileSync(resolved);
7967
+ bytes = fs3.readFileSync(resolved);
7098
7968
  } catch (err) {
7099
7969
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err.message}` }) }] };
7100
7970
  }
@@ -7264,6 +8134,24 @@ var init_proxy_bridge_service = __esm({
7264
8134
  getInstances() {
7265
8135
  return this.cachedInstances;
7266
8136
  }
8137
+ async unregisterInstanceIdEverywhere(instanceId) {
8138
+ const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
8139
+ method: "POST",
8140
+ headers: { "Content-Type": "application/json" },
8141
+ body: JSON.stringify({ instanceId })
8142
+ });
8143
+ if (!response.ok) {
8144
+ const body = await response.text().catch(() => "");
8145
+ throw new Error(`Proxy unregister failed (${response.status}): ${body || response.statusText}`);
8146
+ }
8147
+ const result = await response.json();
8148
+ const removed = Array.isArray(result.removed) ? result.removed : [];
8149
+ const removedKeys = new Set(removed.map((inst) => `${inst.instanceId}\0${inst.role}`));
8150
+ if (removedKeys.size > 0) {
8151
+ this.cachedInstances = this.cachedInstances.filter((inst) => !removedKeys.has(`${inst.instanceId}\0${inst.role}`));
8152
+ }
8153
+ return removed;
8154
+ }
7267
8155
  /** Called when this proxy is being discarded (e.g. promotion to primary
7268
8156
  replaced it). Stops the background refresh so it doesn't leak. */
7269
8157
  stop() {
@@ -7317,7 +8205,7 @@ var init_proxy_bridge_service = __esm({
7317
8205
  // ../core/dist/server.js
7318
8206
  import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
7319
8207
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7320
- import { CallToolRequestSchema as CallToolRequestSchema2, ErrorCode as ErrorCode2, ListToolsRequestSchema as ListToolsRequestSchema2, McpError as McpError2 } from "@modelcontextprotocol/sdk/types.js";
8208
+ import { CallToolRequestSchema as CallToolRequestSchema2, ErrorCode as ErrorCode3, ListToolsRequestSchema as ListToolsRequestSchema2, McpError as McpError3 } from "@modelcontextprotocol/sdk/types.js";
7321
8209
  var RobloxStudioMCPServer;
7322
8210
  var init_server = __esm({
7323
8211
  "../core/dist/server.js"() {
@@ -7326,6 +8214,7 @@ var init_server = __esm({
7326
8214
  init_tools();
7327
8215
  init_bridge_service();
7328
8216
  init_proxy_bridge_service();
8217
+ init_mcp_compat();
7329
8218
  RobloxStudioMCPServer = class {
7330
8219
  server;
7331
8220
  tools;
@@ -7345,6 +8234,7 @@ var init_server = __esm({
7345
8234
  });
7346
8235
  this.bridge = new BridgeService();
7347
8236
  this.tools = new RobloxStudioTools(this.bridge);
8237
+ registerResourceHandlers(this.server);
7348
8238
  this.setupToolHandlers();
7349
8239
  }
7350
8240
  setupToolHandlers() {
@@ -7360,11 +8250,11 @@ var init_server = __esm({
7360
8250
  this.server.setRequestHandler(CallToolRequestSchema2, async (request) => {
7361
8251
  const { name, arguments: args } = request.params;
7362
8252
  if (!this.allowedToolNames.has(name)) {
7363
- throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${name}`);
8253
+ throw new McpError3(ErrorCode3.MethodNotFound, `Unknown tool: ${name}`);
7364
8254
  }
7365
8255
  const handler = TOOL_HANDLERS[name];
7366
8256
  if (!handler) {
7367
- throw new McpError2(ErrorCode2.MethodNotFound, `Unknown tool: ${name}`);
8257
+ throw new McpError3(ErrorCode3.MethodNotFound, `Unknown tool: ${name}`);
7368
8258
  }
7369
8259
  try {
7370
8260
  return await handler(this.tools, args ?? {});
@@ -7382,9 +8272,9 @@ var init_server = __esm({
7382
8272
  isError: true
7383
8273
  };
7384
8274
  }
7385
- if (error instanceof McpError2)
8275
+ if (error instanceof McpError3)
7386
8276
  throw error;
7387
- throw new McpError2(ErrorCode2.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`);
8277
+ throw new McpError3(ErrorCode3.InternalError, `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`);
7388
8278
  }
7389
8279
  });
7390
8280
  }
@@ -8049,7 +8939,7 @@ var init_definitions = __esm({
8049
8939
  {
8050
8940
  name: "get_script_source",
8051
8941
  category: "read",
8052
- description: 'Get script source. Returns "source" and "numberedSource" (line-numbered). Use startLine/endLine for large scripts.',
8942
+ description: 'Get script source. Returns "source" and "numberedSource" (line-numbered). Pass line_range for large scripts; without a range, large scripts are truncated (see the "truncated" flag and "note") to avoid flooding the context.',
8053
8943
  inputSchema: {
8054
8944
  type: "object",
8055
8945
  properties: {
@@ -8057,13 +8947,9 @@ var init_definitions = __esm({
8057
8947
  type: "string",
8058
8948
  description: "Canonical path to a LuaSourceContainer"
8059
8949
  },
8060
- startLine: {
8061
- type: "number",
8062
- description: "Start line (1-indexed)"
8063
- },
8064
- endLine: {
8065
- type: "number",
8066
- description: "End line (inclusive)"
8950
+ line_range: {
8951
+ type: "string",
8952
+ description: 'Line range to return: "start-end" (e.g. "100-200"), open-ended ("100-" or "-200"), or a single line ("42").'
8067
8953
  },
8068
8954
  instance_id: {
8069
8955
  type: "string",
@@ -8099,7 +8985,7 @@ var init_definitions = __esm({
8099
8985
  {
8100
8986
  name: "edit_script_lines",
8101
8987
  category: "write",
8102
- description: "Replace exact text in a script. Without startLine, old_string must match exactly once in the script. Pass startLine (1-indexed, from get_script_source) to anchor the edit to a specific line when old_string is ambiguous (e.g. repeated closing braces).",
8988
+ description: 'Replace exact text in a script. Without line_range, old_string must match exactly once in the script. Pass line_range as a single line (e.g. "42") to anchor the edit when old_string is ambiguous.',
8103
8989
  inputSchema: {
8104
8990
  type: "object",
8105
8991
  properties: {
@@ -8109,15 +8995,15 @@ var init_definitions = __esm({
8109
8995
  },
8110
8996
  old_string: {
8111
8997
  type: "string",
8112
- description: "Exact text to find and replace. Must be unique in the script unless startLine is provided."
8998
+ description: "Exact text to find and replace. Must be unique in the script unless line_range is provided."
8113
8999
  },
8114
9000
  new_string: {
8115
9001
  type: "string",
8116
9002
  description: "Replacement text"
8117
9003
  },
8118
- startLine: {
8119
- type: "number",
8120
- description: "Optional 1-indexed line where old_string begins. When provided, skips uniqueness check and requires old_string to match starting at that exact line."
9004
+ line_range: {
9005
+ type: "string",
9006
+ description: 'Optional single line where old_string begins, such as "42". When provided, skips uniqueness check and requires old_string to match starting at that exact line.'
8121
9007
  },
8122
9008
  instance_id: {
8123
9009
  type: "string",
@@ -8157,7 +9043,7 @@ var init_definitions = __esm({
8157
9043
  {
8158
9044
  name: "delete_script_lines",
8159
9045
  category: "write",
8160
- description: "Delete a range of lines. 1-indexed, inclusive.",
9046
+ description: "Delete a range of lines. line_range is 1-indexed and inclusive.",
8161
9047
  inputSchema: {
8162
9048
  type: "object",
8163
9049
  properties: {
@@ -8165,20 +9051,16 @@ var init_definitions = __esm({
8165
9051
  type: "string",
8166
9052
  description: "Canonical path to a LuaSourceContainer"
8167
9053
  },
8168
- startLine: {
8169
- type: "number",
8170
- description: "Start line (1-indexed)"
8171
- },
8172
- endLine: {
8173
- type: "number",
8174
- description: "End line (inclusive)"
9054
+ line_range: {
9055
+ type: "string",
9056
+ description: 'Line range to delete: "start-end" (e.g. "100-200") or a single line ("42"). Open-ended ranges are not accepted for deletion.'
8175
9057
  },
8176
9058
  instance_id: {
8177
9059
  type: "string",
8178
9060
  description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
8179
9061
  }
8180
9062
  },
8181
- required: ["instancePath", "startLine", "endLine"]
9063
+ required: ["instancePath", "line_range"]
8182
9064
  }
8183
9065
  },
8184
9066
  // === Attributes ===
@@ -8424,21 +9306,21 @@ var init_definitions = __esm({
8424
9306
  {
8425
9307
  name: "grep_scripts",
8426
9308
  category: "read",
8427
- description: "Ripgrep-inspired search across all script sources. Supports literal and Lua pattern matching, context lines, early termination, and results grouped by script with line/column numbers.",
9309
+ description: 'Ripgrep-inspired search across all script sources. Supports literal and Lua pattern matching (with top-level "|" alternation), context lines, early termination, and results grouped by script with line/column numbers.',
8428
9310
  inputSchema: {
8429
9311
  type: "object",
8430
9312
  properties: {
8431
9313
  pattern: {
8432
9314
  type: "string",
8433
- description: "Search pattern (literal string or Lua pattern)"
9315
+ description: 'Search text. Literal by default; with usePattern=true it is a case-sensitive Lua pattern with top-level "|" alternation (e.g. "foo|bar").'
8434
9316
  },
8435
9317
  caseSensitive: {
8436
9318
  type: "boolean",
8437
- description: "Case-sensitive search (default: false)"
9319
+ description: "Literal search case sensitivity (default: false). Lua pattern mode is always case-sensitive; passing false with usePattern=true is rejected."
8438
9320
  },
8439
9321
  usePattern: {
8440
9322
  type: "boolean",
8441
- description: "Use Lua pattern matching instead of literal (default: false)"
9323
+ description: 'Use case-sensitive Lua pattern matching instead of literal search (default: false). Supports top-level alternation: "a|b" matches a line containing "a" or "b". Note: Lua patterns are NOT PCRE \u2014 use %d/%a/%w classes and ".-" (not ".*?"); ^ $ ( ) . % + - * ? [ ] are magic.'
8442
9324
  },
8443
9325
  contextLines: {
8444
9326
  type: "number",
@@ -8477,7 +9359,7 @@ var init_definitions = __esm({
8477
9359
  {
8478
9360
  name: "manage_instance",
8479
9361
  category: "write",
8480
- description: 'Launch, close, inspect, and find revisions for Studio instances. Use action="list_place_versions" with place_id to retrieve version numbers through Open Cloud asset versions, then action="launch" with source="place_revision" and place_version to open an older revision. action="close" can close an MCP-managed instance or an explicitly connected edit instance by instance_id. action="launch" source="published_place" opens the latest published place and is blocked if that place_id is already connected; source="place_revision" is allowed because Studio opens explicit past revisions as anonymous local copies. Requires ROBLOX_OPEN_CLOUD_API_KEY with asset:read for list_place_versions.',
9362
+ description: 'Launch, close, inspect, and find revisions for Studio instances. Use action="launch" with source="baseplate" for a blank place, or source="local_file" with local_place_file for a local place; neither uses place_id. Use action="list_place_versions" with place_id to retrieve version numbers through Open Cloud asset versions, then action="launch" with source="place_revision", place_id, and place_version to open an older revision. action="close" can close an MCP-managed instance or an explicitly connected edit instance by instance_id. action="launch" source="published_place" opens the latest published place and is blocked if that place_id is already connected; source="place_revision" is allowed because Studio opens explicit past revisions as anonymous local copies. Requires ROBLOX_OPEN_CLOUD_API_KEY with asset:read for list_place_versions.',
8481
9363
  inputSchema: {
8482
9364
  type: "object",
8483
9365
  properties: {
@@ -8489,7 +9371,7 @@ var init_definitions = __esm({
8489
9371
  source: {
8490
9372
  type: "string",
8491
9373
  enum: ["baseplate", "local_file", "published_place", "place_revision"],
8492
- description: 'Required for action="launch". published_place opens the latest place; place_revision opens a specific older version as an anonymous local copy.'
9374
+ description: 'Required for action="launch". baseplate/local_file do not use place_id; published_place opens the latest place; place_revision opens a specific older version as an anonymous local copy.'
8493
9375
  },
8494
9376
  local_place_file: {
8495
9377
  type: "string",
@@ -8497,11 +9379,7 @@ var init_definitions = __esm({
8497
9379
  },
8498
9380
  place_id: {
8499
9381
  type: "number",
8500
- description: 'Required for source="published_place", source="place_revision", and action="list_place_versions".'
8501
- },
8502
- universe_id: {
8503
- type: "number",
8504
- description: "Optional for published_place/place_revision launches; derived from place_id when omitted."
9382
+ description: 'Only used for source="published_place", source="place_revision", and action="list_place_versions". Do not pass for source="baseplate" or source="local_file".'
8505
9383
  },
8506
9384
  place_version: {
8507
9385
  type: "number",
@@ -8838,7 +9716,7 @@ var init_definitions = __esm({
8838
9716
  {
8839
9717
  name: "multiplayer_playtest",
8840
9718
  category: "write",
8841
- description: 'Start, inspect, add players to, remove a client from, or end a StudioTestService multiplayer playtest. Use action="start" with numPlayers, action="status", action="add_players" with numPlayers, action="leave_client" with target="client-N", or action="end". Returns brief lifecycle status only; read script output with get_runtime_logs.',
9719
+ description: 'Start or inspect a StudioTestService multiplayer playtest. Use action="start" with numPlayers and force=true only when you accept that MCP cannot stop it and you must manually close the multiplayer test windows afterward. action="status" inspects state, action="add_players" adds players, and action="leave_client" removes one client. action="end" is disabled for now and returns the StudioTestService:EndTest broken-API reason. Returns brief lifecycle status only; read script output with get_runtime_logs.',
8842
9720
  inputSchema: {
8843
9721
  type: "object",
8844
9722
  properties: {
@@ -8859,11 +9737,15 @@ var init_definitions = __esm({
8859
9737
  description: 'For action="start": JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients.'
8860
9738
  },
8861
9739
  value: {
8862
- description: 'For action="end": JSON-compatible value returned to the edit-side ExecuteMultiplayerTestAsync call.'
9740
+ description: 'Ignored while action="end" is disabled.'
9741
+ },
9742
+ force: {
9743
+ type: "boolean",
9744
+ description: 'Required for action="start". Pass true only if you understand StudioTestService:EndTest is broken in this flow and you will manually close the multiplayer test windows.'
8863
9745
  },
8864
9746
  timeout: {
8865
9747
  type: "number",
8866
- description: "Max seconds to wait for action completion. Defaults to 30."
9748
+ description: "Max seconds to wait for start peer detection or action completion. Defaults to 30."
8867
9749
  },
8868
9750
  instance_id: {
8869
9751
  type: "string",
@@ -10098,6 +10980,31 @@ part(0,2,0,2,1,1,"b")`,
10098
10980
  },
10099
10981
  required: ["pattern", "replacement"]
10100
10982
  }
10983
+ },
10984
+ // === Documentation ===
10985
+ {
10986
+ name: "get_roblox_docs",
10987
+ category: "read",
10988
+ description: 'Fetch official Roblox engine API documentation as markdown from create.roblox.com. Call this BEFORE writing or editing code that uses an engine class, enum, datatype, or Luau library you are not fully certain about (e.g. ProximityPrompt, Enum.KeyCode, CFrame, TweenService) \u2014 the page includes the description, properties, methods, events, and code samples. Results are cached, so repeat lookups are cheap. Very large pages are truncated with a section index; pass section (e.g. "Properties", "Methods", "Events") to read one section in full.',
10989
+ inputSchema: {
10990
+ type: "object",
10991
+ properties: {
10992
+ name: {
10993
+ type: "string",
10994
+ description: 'Exact PascalCase name of the class, enum, datatype, or library (e.g. "ProximityPrompt", "KeyCode", "CFrame", "table")'
10995
+ },
10996
+ doc_type: {
10997
+ type: "string",
10998
+ enum: ["classes", "enums", "datatypes", "libraries", "globals"],
10999
+ description: "Documentation category (default: classes)"
11000
+ },
11001
+ section: {
11002
+ type: "string",
11003
+ description: 'Optional "##"-level section to return instead of the whole page (e.g. "Description", "Properties", "Methods", "Events", "Code Samples")'
11004
+ }
11005
+ },
11006
+ required: ["name"]
11007
+ }
10101
11008
  }
10102
11009
  ];
10103
11010
  DEPRECATED_TOOL_DEFINITIONS = [
@@ -10143,7 +11050,7 @@ part(0,2,0,2,1,1,"b")`,
10143
11050
  {
10144
11051
  name: "multiplayer_test_start",
10145
11052
  category: "write",
10146
- description: 'Deprecated. Use multiplayer_playtest with action="start" instead. Starts a StudioTestService multiplayer test.',
11053
+ description: 'Deprecated. Use multiplayer_playtest with action="start" instead. Starts a StudioTestService multiplayer test only when force=true acknowledges that MCP cannot stop it and the test windows must be closed manually.',
10147
11054
  inputSchema: {
10148
11055
  type: "object",
10149
11056
  properties: {
@@ -10154,6 +11061,10 @@ part(0,2,0,2,1,1,"b")`,
10154
11061
  testArgs: {
10155
11062
  description: "JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients."
10156
11063
  },
11064
+ force: {
11065
+ type: "boolean",
11066
+ description: "Required. Pass true only if you understand StudioTestService:EndTest is broken in this flow and you will manually close the multiplayer test windows."
11067
+ },
10157
11068
  timeout: {
10158
11069
  type: "number",
10159
11070
  description: "Max seconds to wait for server + clients to register (default 30)."
@@ -10163,7 +11074,7 @@ part(0,2,0,2,1,1,"b")`,
10163
11074
  description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
10164
11075
  }
10165
11076
  },
10166
- required: ["numPlayers"]
11077
+ required: ["numPlayers", "force"]
10167
11078
  }
10168
11079
  },
10169
11080
  {
@@ -10228,16 +11139,16 @@ part(0,2,0,2,1,1,"b")`,
10228
11139
  {
10229
11140
  name: "multiplayer_test_end",
10230
11141
  category: "write",
10231
- description: 'Deprecated. Use multiplayer_playtest with action="end" instead. Ends a running StudioTestService multiplayer test.',
11142
+ description: "Deprecated. Multiplayer StudioTestService stop/end is disabled for now because StudioTestService:EndTest is broken in this flow. This tool returns a disabled error and does not call EndTest.",
10232
11143
  inputSchema: {
10233
11144
  type: "object",
10234
11145
  properties: {
10235
11146
  value: {
10236
- description: "JSON-compatible value returned to the edit-side ExecuteMultiplayerTestAsync call."
11147
+ description: "Ignored while multiplayer stop/end is disabled."
10237
11148
  },
10238
11149
  timeout: {
10239
11150
  type: "number",
10240
- description: "Max seconds to wait for runtime peers to disconnect (default 30)."
11151
+ description: "Ignored while multiplayer stop/end is disabled."
10241
11152
  },
10242
11153
  instance_id: {
10243
11154
  type: "string",
@@ -10253,15 +11164,15 @@ part(0,2,0,2,1,1,"b")`,
10253
11164
  });
10254
11165
 
10255
11166
  // ../core/dist/install-plugin-helpers.js
10256
- import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync } from "fs";
11167
+ import { existsSync as existsSync4, readFileSync as readFileSync5, unlinkSync } from "fs";
10257
11168
  import { execSync } from "child_process";
10258
- import { join as join3 } from "path";
10259
- import { homedir as homedir3 } from "os";
11169
+ import { join as join4 } from "path";
11170
+ import { homedir as homedir4 } from "os";
10260
11171
  function isWSL() {
10261
11172
  if (process.platform !== "linux")
10262
11173
  return false;
10263
11174
  try {
10264
- const v = readFileSync4("/proc/version", "utf8");
11175
+ const v = readFileSync5("/proc/version", "utf8");
10265
11176
  return /microsoft|wsl/i.test(v);
10266
11177
  } catch {
10267
11178
  return false;
@@ -10279,7 +11190,7 @@ function getWindowsUserPluginsDir() {
10279
11190
  }).toString().trim();
10280
11191
  if (!linuxPath)
10281
11192
  return null;
10282
- return join3(linuxPath, "Roblox", "Plugins");
11193
+ return join4(linuxPath, "Roblox", "Plugins");
10283
11194
  } catch {
10284
11195
  return null;
10285
11196
  }
@@ -10288,7 +11199,7 @@ function getPluginsFolder() {
10288
11199
  if (process.env.MCP_PLUGINS_DIR)
10289
11200
  return process.env.MCP_PLUGINS_DIR;
10290
11201
  if (process.platform === "win32") {
10291
- return join3(process.env.LOCALAPPDATA || join3(homedir3(), "AppData", "Local"), "Roblox", "Plugins");
11202
+ return join4(process.env.LOCALAPPDATA || join4(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
10292
11203
  }
10293
11204
  if (isWSL()) {
10294
11205
  const win = getWindowsUserPluginsDir();
@@ -10296,10 +11207,10 @@ function getPluginsFolder() {
10296
11207
  return win;
10297
11208
  console.warn("[install-plugin] WSL detected but could not resolve Windows %LOCALAPPDATA%. Falling back to ~/Documents/Roblox/Plugins/ - you will likely need to copy the rbxmx to /mnt/c/Users/<you>/AppData/Local/Roblox/Plugins/ manually. Set MCP_PLUGINS_DIR to skip detection.");
10298
11209
  }
10299
- return join3(homedir3(), "Documents", "Roblox", "Plugins");
11210
+ return join4(homedir4(), "Documents", "Roblox", "Plugins");
10300
11211
  }
10301
11212
  function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
10302
- const otherDest = join3(pluginsFolder, otherAssetName);
11213
+ const otherDest = join4(pluginsFolder, otherAssetName);
10303
11214
  if (!existsSync4(otherDest))
10304
11215
  return;
10305
11216
  if (replace) {
@@ -10345,8 +11256,8 @@ __export(install_plugin_exports, {
10345
11256
  installBundledPlugin: () => installBundledPlugin,
10346
11257
  installPlugin: () => installPlugin
10347
11258
  });
10348
- import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, unlinkSync as unlinkSync2 } from "fs";
10349
- import { dirname as dirname3, join as join4 } from "path";
11259
+ import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync6, unlinkSync as unlinkSync2 } from "fs";
11260
+ import { dirname as dirname3, join as join5 } from "path";
10350
11261
  import { fileURLToPath } from "url";
10351
11262
  import { get } from "https";
10352
11263
  function httpsGet(url) {
@@ -10407,7 +11318,7 @@ function prepareInstall({
10407
11318
  }) {
10408
11319
  const pluginsFolder = getPluginsFolder();
10409
11320
  if (!existsSync5(pluginsFolder)) {
10410
- mkdirSync3(pluginsFolder, { recursive: true });
11321
+ mkdirSync4(pluginsFolder, { recursive: true });
10411
11322
  }
10412
11323
  handleVariantConflict({
10413
11324
  pluginsFolder,
@@ -10421,21 +11332,21 @@ function prepareInstall({
10421
11332
  function bundledAssetPath() {
10422
11333
  const currentDir = dirname3(fileURLToPath(import.meta.url));
10423
11334
  const candidates = [
10424
- join4(currentDir, "..", "studio-plugin", ASSET_NAME),
10425
- join4(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
11335
+ join5(currentDir, "..", "studio-plugin", ASSET_NAME),
11336
+ join5(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
10426
11337
  ];
10427
11338
  return candidates.find((candidate) => existsSync5(candidate)) ?? null;
10428
11339
  }
10429
11340
  function packageVersion() {
10430
11341
  const currentDir = dirname3(fileURLToPath(import.meta.url));
10431
- const pkg = JSON.parse(readFileSync5(join4(currentDir, "..", "package.json"), "utf8"));
11342
+ const pkg = JSON.parse(readFileSync6(join5(currentDir, "..", "package.json"), "utf8"));
10432
11343
  if (!pkg.version) {
10433
11344
  throw new Error("Package version not found");
10434
11345
  }
10435
11346
  return pkg.version;
10436
11347
  }
10437
11348
  function bundledPluginVersion(source) {
10438
- const match = readFileSync5(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
11349
+ const match = readFileSync6(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
10439
11350
  return match ? match[1] : null;
10440
11351
  }
10441
11352
  function assertBundledPluginVersion(source) {
@@ -10449,8 +11360,8 @@ function assertBundledPluginVersion(source) {
10449
11360
  }
10450
11361
  function filesMatch(a, b) {
10451
11362
  if (!existsSync5(b)) return false;
10452
- const aBytes = readFileSync5(a);
10453
- const bBytes = readFileSync5(b);
11363
+ const aBytes = readFileSync6(a);
11364
+ const bBytes = readFileSync6(b);
10454
11365
  return aBytes.length === bBytes.length && aBytes.equals(bBytes);
10455
11366
  }
10456
11367
  async function installBundledPlugin(options = {}) {
@@ -10463,7 +11374,7 @@ async function installBundledPlugin(options = {}) {
10463
11374
  }
10464
11375
  assertBundledPluginVersion(source);
10465
11376
  const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
10466
- const dest = join4(pluginsFolder, ASSET_NAME);
11377
+ const dest = join5(pluginsFolder, ASSET_NAME);
10467
11378
  if (filesMatch(source, dest)) return;
10468
11379
  copyFileSync2(source, dest);
10469
11380
  log(`Installed ${ASSET_NAME} to ${dest}`);
@@ -10476,7 +11387,7 @@ async function installPlugin(options = {}) {
10476
11387
  const bundled = bundledAssetPath();
10477
11388
  if (bundled) {
10478
11389
  assertBundledPluginVersion(bundled);
10479
- const dest2 = join4(pluginsFolder, ASSET_NAME);
11390
+ const dest2 = join5(pluginsFolder, ASSET_NAME);
10480
11391
  if (filesMatch(bundled, dest2)) {
10481
11392
  log(`${ASSET_NAME} already installed.`);
10482
11393
  return;
@@ -10491,7 +11402,7 @@ async function installPlugin(options = {}) {
10491
11402
  if (!asset) {
10492
11403
  throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
10493
11404
  }
10494
- const dest = join4(pluginsFolder, ASSET_NAME);
11405
+ const dest = join5(pluginsFolder, ASSET_NAME);
10495
11406
  log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
10496
11407
  await download(asset.browser_download_url, dest);
10497
11408
  log(`Installed to ${dest}`);