@el4cteo/rbx-studio-mcp 0.7.2 → 0.7.5

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.
@@ -1,39 +1,151 @@
1
- /**
2
- * Offline checks for tool output shaping that needs no Studio.
3
- *
4
- * Usage: node scripts/test-tools.mjs
5
- */
6
- import assert from "node:assert/strict";
7
- import { CHARACTER_LIMIT } from "../dist/lib/format.js";
8
- import { clipListing, numbered } from "../dist/tools/scripts.js";
9
-
10
- const item = (path, lineCount) => ({ path, className: "ModuleScript", lineCount, startLine: 1, source: "" });
11
- const listing = (path, lines) =>
12
- `${path} (ModuleScript, ${lines} lines)\n` +
13
- numbered(Array.from({ length: lines }, (_, index) => `local v${index} = ${index}`).join("\n"), 1);
14
-
15
- // Small reads pass through untouched.
16
- {
17
- const blocks = [listing("A", 3)];
18
- assert.equal(clipListing(blocks, [item("A", 3)], null), blocks[0]);
19
- }
20
-
21
- // A big read is cut on a whole line and names the exact next window.
22
- {
23
- const out = clipListing([listing("Big", 5000), listing("After", 5)], [item("Big", 5000), item("After", 5)], null);
24
- assert.ok(out.length <= CHARACTER_LIMIT, "fits the limit");
25
- const match = /Big stops at line (\d+) of 5000\. Continue with \{ path: "Big", startLine: (\d+) \}/.exec(out);
26
- assert.ok(match, "names where to continue");
27
- assert.equal(Number(match[2]), Number(match[1]) + 1);
28
- const lastShown = out.split("\n\n[clipped")[0].split("\n").pop();
29
- assert.match(lastShown, new RegExp(`^\s*${match[1]}│ local v${Number(match[1]) - 1} = ${Number(match[1]) - 1}$`), "last line is whole");
30
- assert.match(out, /1 more script\(s\) after it were not shown/);
31
- }
32
-
33
- // Failures are never the part that gets clipped away.
34
- {
35
- const out = clipListing([listing("Big", 5000)], [item("Big", 5000)], "Could not read 1 path(s):\n - Nope");
36
- assert.ok(out.startsWith("Could not read 1 path(s)"));
37
- }
38
-
39
- process.stdout.write("tools: ok\n");
1
+ /**
2
+ * Offline checks for tool output shaping that needs no Studio.
3
+ *
4
+ * Usage: node scripts/test-tools.mjs
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { CHARACTER_LIMIT } from "../dist/lib/format.js";
8
+ import { clipListing, numbered } from "../dist/tools/scripts.js";
9
+
10
+ const item = (path, lineCount) => ({ path, className: "ModuleScript", lineCount, startLine: 1, source: "" });
11
+ const listing = (path, lines) =>
12
+ `${path} (ModuleScript, ${lines} lines)\n` +
13
+ numbered(Array.from({ length: lines }, (_, index) => `local v${index} = ${index}`).join("\n"), 1);
14
+
15
+ // Small reads pass through untouched.
16
+ {
17
+ const blocks = [listing("A", 3)];
18
+ assert.equal(clipListing(blocks, [item("A", 3)], null), blocks[0]);
19
+ }
20
+
21
+ // A big read is cut on a whole line and names the exact next window.
22
+ {
23
+ const out = clipListing([listing("Big", 5000), listing("After", 5)], [item("Big", 5000), item("After", 5)], null);
24
+ assert.ok(out.length <= CHARACTER_LIMIT, "fits the limit");
25
+ const match = /Big stops at line (\d+) of 5000\. Continue with \{ path: "Big", startLine: (\d+) \}/.exec(out);
26
+ assert.ok(match, "names where to continue");
27
+ assert.equal(Number(match[2]), Number(match[1]) + 1);
28
+ const lastShown = out.split("\n\n[clipped")[0].split("\n").pop();
29
+ assert.match(lastShown, new RegExp(`^\s*${match[1]}│ local v${Number(match[1]) - 1} = ${Number(match[1]) - 1}$`), "last line is whole");
30
+ assert.match(out, /1 more script\(s\) after it were not shown/);
31
+ }
32
+
33
+ // Failures are never the part that gets clipped away.
34
+ {
35
+ const out = clipListing([listing("Big", 5000)], [item("Big", 5000)], "Could not read 1 path(s):\n - Nope");
36
+ assert.ok(out.startsWith("Could not read 1 path(s)"));
37
+ }
38
+
39
+ process.stdout.write("tools: ok\n");
40
+
41
+ // Exercise the public schemas and forwarding through the real tool handlers.
42
+ const { z } = await import("zod");
43
+ const { registerExecTools } = await import("../dist/tools/exec.js");
44
+ const { registerInputTools } = await import("../dist/tools/input.js");
45
+ const registered = new Map();
46
+ const calls = [];
47
+ const context = {
48
+ server: { registerTool(name, spec, handler) { registered.set(name, { spec, handler }); } },
49
+ bridge: { async call(op, params, options) {
50
+ calls.push({op, params, options});
51
+ return op === "exec.run" ? {ok:true,returned:["nil",{answer:42}],output:[],milliseconds:1} : {delivered:true,steps:1,player:"Alice"};
52
+ } },
53
+ };
54
+ registerExecTools(context);
55
+ registerInputTools(context);
56
+ const execute = registered.get("execute_luau");
57
+ const parsed = z.object(execute.spec.inputSchema).parse({source:"return nil",target:"client",player:"Alice",timeoutSeconds:2});
58
+ await execute.handler(parsed);
59
+ assert.equal(calls.at(-1).params.target, "client");
60
+ assert.equal(calls.at(-1).params.player, "Alice");
61
+ assert.equal(calls.at(-1).options.timeoutMs, 12000);
62
+ await execute.handler(z.object(execute.spec.inputSchema).parse({source:"return 1"}));
63
+ assert.equal(calls.at(-1).params.target, "studio");
64
+ assert.equal(calls.at(-1).options.timeoutMs, 60000);
65
+ const input = registered.get("input");
66
+ for (const step of [{kind:"click",target:"PlayerGui.HUD.BuyButton"},{kind:"text",target:"PlayerGui.HUD.NameBox",text:"hello"},{kind:"click",x:100,y:200}]) {
67
+ await input.handler(z.object(input.spec.inputSchema).parse({steps:[step]}));
68
+ assert.deepEqual(calls.at(-1).params.steps[0], step);
69
+ }
70
+ assert.deepEqual([...registered.keys()].sort(), ["execute_luau", "input", "viewport"]);
71
+ process.stdout.write("client tool schemas: ok\n");
72
+
73
+ const { registerDebugTools } = await import("../dist/tools/debug.js");
74
+ registerDebugTools(context);
75
+ const debug = registered.get("debug");
76
+ const remoteArgs = z.object(debug.spec.inputSchema).parse({op:"remotes",path:"ReplicatedStorage",player:"Alice",seconds:3});
77
+ await debug.handler(remoteArgs);
78
+ assert.equal(calls.at(-1).op, "debug.remotes");
79
+ assert.deepEqual(calls.at(-1).params, {path:"ReplicatedStorage",player:"Alice",seconds:3});
80
+ assert.equal(calls.at(-1).options.timeoutMs, 18000);
81
+ assert.throws(() => z.object(debug.spec.inputSchema).parse({op:"remotes",seconds:16}));
82
+ assert.deepEqual([...registered.keys()].sort(), ["debug", "execute_luau", "input", "viewport"]);
83
+ process.stdout.write("remote trace schema: ok\n");
84
+
85
+ const { registerPlaytestTools } = await import("../dist/tools/playtest.js");
86
+ registerPlaytestTools(context);
87
+ const playtest = registered.get("playtest");
88
+ for (const guidance of ["AGENTS.md", "CLAUDE.md", "user instructions", "project guidance", "even when ON", "Do not bypass"]) {
89
+ assert.ok(playtest.spec.description.includes(guidance), `playtest guidance preserves ${guidance}`);
90
+ }
91
+ const normalCall = context.bridge.call;
92
+ context.bridge.call = async (op, params) => {
93
+ if (["play", "run", "multiplayer"].includes(params.op)) {
94
+ const { ToolError } = await import("../dist/lib/errors.js");
95
+ throw new ToolError("PLAYTEST_DISABLED", "Playtests are disabled by the user.", "Do not start or simulate a playtest. Continue using edit-mode tools and static inspection where possible.\nRun `playtests on` in the Studio MCP panel to re-enable playtesting.");
96
+ }
97
+ return {changed:false,state:{playtestsAllowed:false}};
98
+ };
99
+ for (const op of ["play", "run", "multiplayer"]) {
100
+ const result = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op}));
101
+ assert.equal(result.isError, true);
102
+ assert.match(result.content[0].text, /PLAYTEST_DISABLED/);
103
+ assert.match(result.content[0].text, /playtests on/);
104
+ }
105
+ const stateResult = await playtest.handler(z.object(playtest.spec.inputSchema).parse({op:"state"}));
106
+ assert.ok(!stateResult.isError);
107
+ context.bridge.call = normalCall;
108
+ process.stdout.write("playtest lock errors and instruction precedence: ok\n");
109
+
110
+ // OFF affects simulation starts, not the ordinary edit-mode tool paths.
111
+ const { registerDiscoverTools } = await import("../dist/tools/discover.js");
112
+ const { registerScriptTools } = await import("../dist/tools/scripts.js");
113
+ const { registerScreenshotTools } = await import("../dist/tools/screenshot.js");
114
+ registerDiscoverTools(context);
115
+ registerScriptTools(context);
116
+ registerScreenshotTools(context);
117
+ const editOps = [];
118
+ context.bridge.sessions = async () => ({list:[{studioId:"edit",context:"edit"}],activeId:"edit"});
119
+ context.bridge.call = async (op, params) => {
120
+ editOps.push(op);
121
+ if (op === "playtest.control") {
122
+ if (["play", "run", "multiplayer"].includes(params.op)) throw new Error("PLAYTEST_DISABLED");
123
+ return {changed:false,state:{playtestsAllowed:false}};
124
+ }
125
+ const results = {
126
+ "exec.run": {ok:true,returned:[42],output:[],milliseconds:1},
127
+ "discover.tree": {items:[],total:0,offset:0},
128
+ "discover.inspect": {items:[],failures:[]},
129
+ "script.read": {items:[],failures:[]},
130
+ "viewport.ui": {findings:[],checked:0,hidden:0,root:"StarterGui",screen:"800x600"},
131
+ "capture.screenshot": {encoding:"png",data:"AA==",width:1,height:1,sourceWidth:1,sourceHeight:1,bytes:1,context:"edit"},
132
+ };
133
+ assert.ok(op in results, `unexpected edit-mode operation ${op}`);
134
+ return results[op];
135
+ };
136
+ for (const [name, args, expected] of [
137
+ ["execute_luau",{source:"return 42",target:"studio"},"exec.run"],
138
+ ["tree",{},"discover.tree"],
139
+ ["inspect",{paths:["Workspace"],detail:"concise"},"discover.inspect"],
140
+ ["script_read",{paths:["ServerScriptService.Example"]},"script.read"],
141
+ ["viewport",{op:"ui",path:"StarterGui"},"viewport.ui"],
142
+ ["screenshot",{},"capture.screenshot"],
143
+ ]) {
144
+ const tool = registered.get(name);
145
+ const result = await tool.handler(z.object(tool.spec.inputSchema).parse(args));
146
+ assert.ok(!result.isError, `${name} remains usable with playtests OFF: ${JSON.stringify(result)}`);
147
+ assert.equal(editOps.at(-1),expected);
148
+ }
149
+ assert.ok(!editOps.includes("playtest.control"), "edit-mode tools never start or probe a simulation");
150
+ context.bridge.call = normalCall;
151
+ process.stdout.write("playtest lock leaves edit-mode tools available: ok\n");