@chrrxs/robloxstudio-mcp-inspector 2.19.1 → 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,23 +454,202 @@ 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
+
454
587
  // ../core/dist/mcp-compat.js
455
588
  import { ErrorCode, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
456
- function registerEmptyResourceShim(server) {
589
+ function registerResourceHandlers(server) {
457
590
  server.registerCapabilities({ resources: {} });
458
591
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
459
592
  resources: []
460
593
  }));
461
594
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
462
- resourceTemplates: []
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
+ ]
463
627
  }));
464
628
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
465
- throw new McpError(ErrorCode.InvalidParams, `Resource ${request.params.uri} not found`);
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
+ }
466
647
  });
467
648
  }
468
649
  var init_mcp_compat = __esm({
469
650
  "../core/dist/mcp-compat.js"() {
470
651
  "use strict";
652
+ init_roblox_docs();
471
653
  }
472
654
  });
473
655
 
@@ -658,6 +840,15 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
658
840
  }
659
841
  res.json({ success: true });
660
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
+ });
661
852
  app.get("/status", (req, res) => {
662
853
  const instances = bridge.getInstances();
663
854
  const publicInstances = instances.map(toPublic);
@@ -775,7 +966,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
775
966
  try {
776
967
  trackMCPActivity();
777
968
  const server = new Server({ name: serverConfig.name, version: serverConfig.version }, { capabilities: { tools: {} } });
778
- registerEmptyResourceShim(server);
969
+ registerResourceHandlers(server);
779
970
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
780
971
  tools: filteredTools.map((t) => ({
781
972
  name: t.name,
@@ -918,6 +1109,7 @@ var init_http_server = __esm({
918
1109
  init_bridge_service();
919
1110
  init_mcp_compat();
920
1111
  TOOL_HANDLERS = {
1112
+ get_roblox_docs: (tools, body) => tools.getRobloxDocs(body.name, body.doc_type, body.section),
921
1113
  get_file_tree: (tools, body) => tools.getFileTree(body.path, body.instance_id),
922
1114
  search_files: (tools, body) => tools.searchFiles(body.query, body.searchType, body.instance_id),
923
1115
  get_place_info: (tools, body) => tools.getPlaceInfo(body.instance_id),
@@ -4388,6 +4580,7 @@ var init_tools = __esm({
4388
4580
  init_roblox_cookie_client();
4389
4581
  init_studio_instance_manager();
4390
4582
  init_image_decode();
4583
+ init_roblox_docs();
4391
4584
  init_jpeg_encoder();
4392
4585
  init_png_encoder();
4393
4586
  MAX_INLINE_IMAGE_BYTES = 6e6;
@@ -4459,6 +4652,17 @@ var init_tools = __esm({
4459
4652
  _textResult(body) {
4460
4653
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
4461
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
+ }
4462
4666
  _parseTextResult(result) {
4463
4667
  const text = result?.content?.[0]?.text;
4464
4668
  if (typeof text !== "string")
@@ -6094,9 +6298,9 @@ ${code}`
6094
6298
  if (instance_id) {
6095
6299
  const managedClose = this.instanceManager.closeByInstanceId(instance_id);
6096
6300
  if (managedClose.status !== "not_found") {
6097
- this.bridge.unregisterInstanceId(instance_id);
6301
+ await this.bridge.unregisterInstanceIdEverywhere(instance_id);
6098
6302
  await sleep(500);
6099
- this.bridge.unregisterInstanceId(instance_id);
6303
+ await this.bridge.unregisterInstanceIdEverywhere(instance_id);
6100
6304
  return this._textResult({
6101
6305
  instance_id,
6102
6306
  message: managedClose.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
@@ -6119,7 +6323,7 @@ ${code}`
6119
6323
  instance_id
6120
6324
  });
6121
6325
  }
6122
- this.bridge.unregisterInstanceId(instance_id);
6326
+ await this.bridge.unregisterInstanceIdEverywhere(instance_id);
6123
6327
  return this._textResult({
6124
6328
  instance_id,
6125
6329
  message: "Studio instance closed."
@@ -6138,11 +6342,11 @@ ${code}`
6138
6342
  record2 = active[0];
6139
6343
  }
6140
6344
  if (record2.instanceId)
6141
- this.bridge.unregisterInstanceId(record2.instanceId);
6345
+ await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
6142
6346
  const closeResult = this.instanceManager.close(record2);
6143
6347
  if (record2.instanceId) {
6144
6348
  await sleep(500);
6145
- this.bridge.unregisterInstanceId(record2.instanceId);
6349
+ await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
6146
6350
  }
6147
6351
  return this._textResult({
6148
6352
  instance_id: record2.instanceId,
@@ -7930,6 +8134,24 @@ var init_proxy_bridge_service = __esm({
7930
8134
  getInstances() {
7931
8135
  return this.cachedInstances;
7932
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
+ }
7933
8155
  /** Called when this proxy is being discarded (e.g. promotion to primary
7934
8156
  replaced it). Stops the background refresh so it doesn't leak. */
7935
8157
  stop() {
@@ -8012,7 +8234,7 @@ var init_server = __esm({
8012
8234
  });
8013
8235
  this.bridge = new BridgeService();
8014
8236
  this.tools = new RobloxStudioTools(this.bridge);
8015
- registerEmptyResourceShim(this.server);
8237
+ registerResourceHandlers(this.server);
8016
8238
  this.setupToolHandlers();
8017
8239
  }
8018
8240
  setupToolHandlers() {
@@ -10758,6 +10980,31 @@ part(0,2,0,2,1,1,"b")`,
10758
10980
  },
10759
10981
  required: ["pattern", "replacement"]
10760
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
+ }
10761
11008
  }
10762
11009
  ];
10763
11010
  DEPRECATED_TOOL_DEFINITIONS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.19.1",
3
+ "version": "2.20.0",
4
4
  "description": "Read-only MCP server for inspecting and debugging Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1413,9 +1413,9 @@ local function computeBridgeStamp()
1413
1413
  for i = 1, #combined do
1414
1414
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1415
1415
  end
1416
- -- "2.19.1" is replaced with the package version at package time
1416
+ -- "2.20.0" is replaced with the package version at package time
1417
1417
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1418
- return `{tostring(h)}-2.19.1`
1418
+ return `{tostring(h)}-2.20.0`
1419
1419
  end
1420
1420
  local BRIDGE_STAMP = computeBridgeStamp()
1421
1421
  local function setSource(scriptInst, source)
@@ -10745,7 +10745,7 @@ return {
10745
10745
  <Properties>
10746
10746
  <string name="Name">State</string>
10747
10747
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10748
- local CURRENT_VERSION = "2.19.1"
10748
+ local CURRENT_VERSION = "2.20.0"
10749
10749
  local PLUGIN_VARIANT = "inspector"
10750
10750
  local BASE_PORT = 58741
10751
10751
  local function createConnection(port)
@@ -1413,9 +1413,9 @@ local function computeBridgeStamp()
1413
1413
  for i = 1, #combined do
1414
1414
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1415
1415
  end
1416
- -- "2.19.1" is replaced with the package version at package time
1416
+ -- "2.20.0" is replaced with the package version at package time
1417
1417
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1418
- return `{tostring(h)}-2.19.1`
1418
+ return `{tostring(h)}-2.20.0`
1419
1419
  end
1420
1420
  local BRIDGE_STAMP = computeBridgeStamp()
1421
1421
  local function setSource(scriptInst, source)
@@ -10745,7 +10745,7 @@ return {
10745
10745
  <Properties>
10746
10746
  <string name="Name">State</string>
10747
10747
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10748
- local CURRENT_VERSION = "2.19.1"
10748
+ local CURRENT_VERSION = "2.20.0"
10749
10749
  local PLUGIN_VARIANT = "main"
10750
10750
  local BASE_PORT = 58741
10751
10751
  local function createConnection(port)