@el4cteo/rbx-studio-mcp 0.4.2 → 0.4.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.
Files changed (38) hide show
  1. package/README.md +211 -211
  2. package/dist/bridge/api.js +3 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +13 -0
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/remote.js +15 -1
  7. package/dist/bridge/remote.js.map +1 -1
  8. package/dist/bridge/rpc.js +35 -6
  9. package/dist/bridge/rpc.js.map +1 -1
  10. package/dist/bridge/server.js +55 -7
  11. package/dist/bridge/server.js.map +1 -1
  12. package/dist/doctor.js +160 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/index.js +29 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/protocol.js.map +1 -1
  17. package/dist/tools/exec.js +53 -3
  18. package/dist/tools/exec.js.map +1 -1
  19. package/dist/tools/generate.js +120 -0
  20. package/dist/tools/generate.js.map +1 -0
  21. package/dist/tools/scripts.js +20 -1
  22. package/dist/tools/scripts.js.map +1 -1
  23. package/dist/tools/world.js +203 -20
  24. package/dist/tools/world.js.map +1 -1
  25. package/package.json +2 -1
  26. package/plugin/src/Config.luau +1 -1
  27. package/plugin/src/Console.luau +233 -9
  28. package/plugin/src/Transport.luau +6 -1
  29. package/plugin/src/Visuals.luau +52 -1
  30. package/plugin/src/handlers/Assets.luau +352 -145
  31. package/plugin/src/handlers/Generate.luau +386 -0
  32. package/plugin/src/handlers/Geometry.luau +170 -0
  33. package/plugin/src/handlers/Scripts.luau +79 -1
  34. package/plugin/src/handlers/Viewport.luau +416 -302
  35. package/plugin/src/handlers/World.luau +15 -5
  36. package/plugin/src/init.server.luau +725 -674
  37. package/scripts/test-bridge.mjs +56 -0
  38. package/scripts/test-failover.mjs +136 -95
@@ -209,3 +209,59 @@ function twoStudios() {
209
209
  owner.goodbye();
210
210
  assert.equal(bridge.clientCount(), 0, "goodbye still drops it");
211
211
  }
212
+
213
+
214
+ /**
215
+ * The console asks "who is connected", not just "how many".
216
+ *
217
+ * A bare count is the thing users bring to us asking whether something is
218
+ * wrong -- three agents they started and three processes they forgot read
219
+ * identically. The roster is what answers it, so the ordering, the naming and
220
+ * the "connected at" all have to survive a keepalive.
221
+ */
222
+ {
223
+ const bridge = new Bridge();
224
+ const alice = new LocalBridge(bridge);
225
+ const bob = new LocalBridge(bridge);
226
+
227
+ // Nameless until the MCP handshake lands, and honest about it.
228
+ assert.deepEqual(
229
+ bridge.clientList().map((client) => client.name),
230
+ ["unknown", "unknown"],
231
+ "a client that has not introduced itself is not guessed at",
232
+ );
233
+
234
+ alice.describe({ name: "claude-code", version: "2.0" });
235
+ bob.describe({ name: "codex", version: "1.0" });
236
+
237
+ const named = bridge.clientList();
238
+ assert.deepEqual(
239
+ named.map((client) => client.name),
240
+ ["claude-code", "codex"],
241
+ "the roster is ordered by arrival, not by name",
242
+ );
243
+ assert.equal(named[0].version, "2.0");
244
+ assert.ok(named[0].pid > 0, "a client reports which process it is");
245
+
246
+ // A keepalive proves a client is still here. It does not make it new.
247
+ const arrived = bridge.clientList()[0].connectedAt;
248
+ const real = Date.now;
249
+ Date.now = () => real() + 60_000;
250
+ try {
251
+ await alice.sessions();
252
+ } finally {
253
+ Date.now = real;
254
+ }
255
+ assert.equal(bridge.clientList()[0].connectedAt, arrived, "working does not reset the clock");
256
+ assert.equal(bridge.clientList()[0].name, "claude-code", "working does not forget the name");
257
+
258
+ // Learning a name is news, so the plugin is told; a repeat is not.
259
+ let announced = 0;
260
+ bridge.watchClients(() => {
261
+ announced += 1;
262
+ });
263
+ alice.describe({ name: "claude-code", version: "2.0" });
264
+ assert.equal(announced, 0, "re-stating the same name announces nothing");
265
+ alice.describe({ name: "cursor", version: "1.0" });
266
+ assert.equal(announced, 1, "a client that changes its name is announced");
267
+ }
@@ -1,95 +1,136 @@
1
- /**
2
- * Checks that the bridge port outlives the process that happened to bind it.
3
- *
4
- * Exactly one process can hold the port the Studio plugin dials; every other
5
- * MCP client that starts this server proxies through it. The bug this covers is
6
- * what used to happen when the holder exited: the peers kept posting to a socket
7
- * nobody was listening on, so every tool call failed and the plugin's console
8
- * sat on "Nothing is listening on port 44755" -- with a healthy server process
9
- * still running that could have taken over. Reported by a user whose agent said
10
- * it was connected while the console showed the opposite.
11
- *
12
- * Runs against real sockets on a spare loopback port, because the thing being
13
- * tested is the bind race itself and a fake would test the fake.
14
- *
15
- * Usage: node scripts/test-failover.mjs
16
- */
17
- import assert from "node:assert/strict";
18
- import { createServer } from "node:http";
19
- import { startBridgeServer } from "../dist/bridge/server.js";
20
- import { CLIENT_HEADER } from "../dist/lib/protocol.js";
21
-
22
- const PORT = 44799;
23
-
24
- /** Waits for `check` to hold, or gives up loudly rather than hanging the suite. */
25
- async function until(check, what, timeoutMs = 20_000) {
26
- const deadline = Date.now() + timeoutMs;
27
- while (Date.now() < deadline) {
28
- if (await check()) return;
29
- await new Promise((resolve) => setTimeout(resolve, 100));
30
- }
31
- assert.fail(`timed out waiting for ${what}`);
32
- }
33
-
34
- function handshake(port, studioId) {
35
- return fetch(`http://127.0.0.1:${port}/connect`, {
36
- method: "POST",
37
- headers: { [CLIENT_HEADER]: "test", "Content-Type": "application/json" },
38
- body: JSON.stringify({
39
- studioId,
40
- placeName: "Test place",
41
- placeId: 1,
42
- pluginVersion: "test",
43
- buildId: "test",
44
- protocolVersion: 1,
45
- transport: "poll",
46
- context: "edit",
47
- }),
48
- });
49
- }
50
-
51
- // A peer takes the port over when the owner exits, and really serves on it.
52
- {
53
- const owner = await startBridgeServer({ port: PORT });
54
- assert.equal(owner.owner, true, "the first instance owns the port");
55
-
56
- const peer = await startBridgeServer({ port: PORT });
57
- assert.equal(peer.owner, false, "the second instance proxies");
58
-
59
- // Both see the same Studio, because there is only one bridge.
60
- await handshake(PORT, "studio-a");
61
- assert.equal((await peer.bridge.sessions()).list.length, 1, "the peer sees the owner's session");
62
-
63
- await owner.close();
64
- await until(() => peer.owner, "the peer to take the port over");
65
-
66
- // Owning the port is not the claim; serving on it is. The plugin reconnects
67
- // after a handover, so a fresh handshake has to land on the new owner.
68
- const response = await handshake(PORT, "studio-b");
69
- assert.equal(response.status, 200, "the new owner answers the plugin");
70
- const sessions = await peer.bridge.sessions();
71
- assert.deepEqual(
72
- sessions.list.map((session) => session.studioId),
73
- ["studio-b"],
74
- "the new owner serves its own bridge",
75
- );
76
-
77
- await peer.bridge.goodbye();
78
- await peer.close();
79
- }
80
-
81
- // A stranger on the port is still refused rather than proxied to.
82
- {
83
- const stranger = createServer((_req, res) => {
84
- res.writeHead(404).end();
85
- });
86
- await new Promise((resolve) => stranger.listen(PORT, "127.0.0.1", resolve));
87
- await assert.rejects(
88
- () => startBridgeServer({ port: PORT }),
89
- /is in use by something that is not roblox-studio-mcp/,
90
- "posting Luau at an unknown server is never the right move",
91
- );
92
- await new Promise((resolve) => stranger.close(resolve));
93
- }
94
-
95
- process.stdout.write("failover: ok\n");
1
+ /**
2
+ * Checks that the bridge port outlives the process that happened to bind it.
3
+ *
4
+ * Exactly one process can hold the port the Studio plugin dials; every other
5
+ * MCP client that starts this server proxies through it. The bug this covers is
6
+ * what used to happen when the holder exited: the peers kept posting to a socket
7
+ * nobody was listening on, so every tool call failed and the plugin's console
8
+ * sat on "Nothing is listening on port 44755" -- with a healthy server process
9
+ * still running that could have taken over. Reported by a user whose agent said
10
+ * it was connected while the console showed the opposite.
11
+ *
12
+ * Runs against real sockets on a spare loopback port, because the thing being
13
+ * tested is the bind race itself and a fake would test the fake.
14
+ *
15
+ * Usage: node scripts/test-failover.mjs
16
+ */
17
+ import assert from "node:assert/strict";
18
+ import { createServer } from "node:http";
19
+ import { startBridgeServer } from "../dist/bridge/server.js";
20
+ import { CLIENT_HEADER } from "../dist/lib/protocol.js";
21
+ import { PEER_HEADER } from "../dist/bridge/remote.js";
22
+
23
+ const PORT = 44799;
24
+
25
+ /** Waits for `check` to hold, or gives up loudly rather than hanging the suite. */
26
+ async function until(check, what, timeoutMs = 20_000) {
27
+ const deadline = Date.now() + timeoutMs;
28
+ while (Date.now() < deadline) {
29
+ if (await check()) return;
30
+ await new Promise((resolve) => setTimeout(resolve, 100));
31
+ }
32
+ assert.fail(`timed out waiting for ${what}`);
33
+ }
34
+
35
+ function handshake(port, studioId) {
36
+ return fetch(`http://127.0.0.1:${port}/connect`, {
37
+ method: "POST",
38
+ headers: { [CLIENT_HEADER]: "test", "Content-Type": "application/json" },
39
+ body: JSON.stringify({
40
+ studioId,
41
+ placeName: "Test place",
42
+ placeId: 1,
43
+ pluginVersion: "test",
44
+ buildId: "test",
45
+ protocolVersion: 1,
46
+ transport: "poll",
47
+ context: "edit",
48
+ }),
49
+ });
50
+ }
51
+
52
+ // A peer takes the port over when the owner exits, and really serves on it.
53
+ {
54
+ const owner = await startBridgeServer({ port: PORT });
55
+ assert.equal(owner.owner, true, "the first instance owns the port");
56
+
57
+ const peer = await startBridgeServer({ port: PORT });
58
+ assert.equal(peer.owner, false, "the second instance proxies");
59
+
60
+ // Both see the same Studio, because there is only one bridge.
61
+ await handshake(PORT, "studio-a");
62
+ assert.equal((await peer.bridge.sessions()).list.length, 1, "the peer sees the owner's session");
63
+
64
+ await owner.close();
65
+ await until(() => peer.owner, "the peer to take the port over");
66
+
67
+ // Owning the port is not the claim; serving on it is. The plugin reconnects
68
+ // after a handover, so a fresh handshake has to land on the new owner.
69
+ const response = await handshake(PORT, "studio-b");
70
+ assert.equal(response.status, 200, "the new owner answers the plugin");
71
+ const sessions = await peer.bridge.sessions();
72
+ assert.deepEqual(
73
+ sessions.list.map((session) => session.studioId),
74
+ ["studio-b"],
75
+ "the new owner serves its own bridge",
76
+ );
77
+
78
+ await peer.bridge.goodbye();
79
+ await peer.close();
80
+ }
81
+
82
+ // A stranger on the port is still refused rather than proxied to.
83
+ {
84
+ const stranger = createServer((_req, res) => {
85
+ res.writeHead(404).end();
86
+ });
87
+ await new Promise((resolve) => stranger.listen(PORT, "127.0.0.1", resolve));
88
+ await assert.rejects(
89
+ () => startBridgeServer({ port: PORT }),
90
+ /is in use by something that is not roblox-studio-mcp/,
91
+ "posting Luau at an unknown server is never the right move",
92
+ );
93
+ await new Promise((resolve) => stranger.close(resolve));
94
+ }
95
+
96
+ //[[ Reading the roster does not join it.
97
+ //
98
+ // `GET /sessions` used to register whoever asked, so a one-shot read put a
99
+ // nameless client on the roster for the 90 seconds until the reaper swept it.
100
+ // `doctor` does exactly that read, which meant running a health check made the
101
+ // console say "2 MCP clients connected" and name one of them "unknown" with
102
+ // pid 0 -- indistinguishable from an agent the user had already closed, and
103
+ // duly reported as a bug.
104
+ //]]
105
+ {
106
+ const owner = await startBridgeServer({ port: PORT });
107
+
108
+ // The count comes back on /hello, so the whole check runs over the wire --
109
+ // which is the only place the bug ever existed.
110
+ const hello = async () => {
111
+ const sent = await fetch(`http://127.0.0.1:${PORT}/hello`, {
112
+ method: "POST",
113
+ headers: {
114
+ [CLIENT_HEADER]: "test",
115
+ [PEER_HEADER]: "peer-counted",
116
+ "Content-Type": "application/json",
117
+ },
118
+ body: JSON.stringify({ name: "peer", version: "1", pid: 1234 }),
119
+ });
120
+ return (await sent.json()).clients;
121
+ };
122
+
123
+ const before = await hello();
124
+
125
+ const read = await fetch(`http://127.0.0.1:${PORT}/sessions`, {
126
+ headers: { [CLIENT_HEADER]: "doctor" },
127
+ });
128
+ assert.equal(read.status, 200, "the roster is still readable");
129
+ await read.json();
130
+
131
+ assert.equal(await hello(), before, "reading /sessions does not add a client");
132
+
133
+ await owner.close();
134
+ }
135
+
136
+ process.stdout.write("failover: ok\n");