@el4cteo/rbx-studio-mcp 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Roblox Studio MCP
2
2
 
3
- MCP server for Roblox Studio. 29 tools over a push-based bridge. MIT.
3
+ MCP server for Roblox Studio. 29 tools over a push-based bridge, every write batched and undoable in one step. MIT.
4
+
5
+ ![The Studio MCP panel, showing calls and their latency](docs/console.png)
4
6
 
5
7
  ## Install
6
8
 
@@ -10,7 +12,7 @@ MCP server for Roblox Studio. 29 tools over a push-based bridge. MIT.
10
12
  npx -y @el4cteo/rbx-studio-mcp --install-plugin
11
13
  ```
12
14
 
13
- Or download `StudioMCP.rbxmx` from [Releases](https://github.com/EL4CTEO/roblox-studio-mcp/releases) into your Studio plugins folder.
15
+ Or download `StudioMCP.rbxmx` from [Releases](https://github.com/EL4CTEO/rbx-studio-mcp/releases) into your Studio plugins folder.
14
16
 
15
17
  **2. The server**, in whichever client you use:
16
18
 
@@ -145,8 +147,16 @@ args = ["-y", "@el4cteo/rbx-studio-mcp"]
145
147
 
146
148
  **3.** Open Studio, accept the `127.0.0.1` prompt, check the **Studio MCP** toolbar button. Verify with `studio_status`.
147
149
 
150
+ **4. For `debug`**, turn on **Debugger Luau API** in File → Beta Features, then restart Studio. Everything else works without it; breakpoints do not, because `ScriptDebuggerService` is not registered until this is enabled.
151
+
152
+ ![The Debugger Luau API beta feature toggle in Studio](docs/betatoggle.png)
153
+
148
154
  Port defaults to **44755** — `--port` or `ROBLOX_STUDIO_MCP_PORT`, matched in the plugin widget. Loopback only.
149
155
 
156
+ **Several agents at once work.** Register this in as many clients as you like — two Claude sessions, Claude plus Cursor, whatever. The plugin connects out to one port, so the first server to start owns it and the rest proxy through it automatically. Nothing to configure, and no second connection to Studio.
157
+
158
+ Each agent keeps its **own** target: `set_active_studio` binds per client, so two agents can work on two open places at once and neither can retarget the other. Pass `studioId` on a single call to reach elsewhere without changing your default.
159
+
150
160
  ## Tools
151
161
 
152
162
  | | |
@@ -159,7 +169,41 @@ Port defaults to **44755** — `--port` or `ROBLOX_STUDIO_MCP_PORT`, matched in
159
169
  | **Look** | `screenshot` `viewport` `device` |
160
170
  | **Session** | `list_studios` `set_active_studio` |
161
171
 
162
- Gotchas: `screenshot` doesn't work mid-playtest. `debug` needs **API debugger Luau** in `File → Beta Features`. `device` emulation persists until `device op="stop"`.
172
+ Gotchas: `debug` needs **API debugger Luau** in `File → Beta Features`. `device` emulation persists until `device op="stop"`. During a playtest two sessions connect — pass `studioId` explicitly, using the edit session for anything that must persist.
173
+
174
+ ## Batching
175
+
176
+ Every write tool takes an array. Editing ten scripts, deleting two hundred
177
+ parts or anchoring a whole map is **one call**, not a loop.
178
+
179
+ | tool | takes | cap |
180
+ |---|---|---|
181
+ | `create` | instances, each nesting `children` to any depth | 100 |
182
+ | `modify` | entries, each with an unlimited list of `paths` | 100 entries |
183
+ | `delete` | paths | 200 |
184
+ | `move` | moves | 200 |
185
+ | `script_edit` | edits, across any number of scripts | 50 |
186
+ | `script_create` | scripts | 50 |
187
+ | `inspect` | paths | 50 |
188
+ | `input` | input steps, delivered in order | 40 |
189
+
190
+ The cap on `modify` is on *entries*, not targets — one entry can anchor five
191
+ hundred parts, so pair it with `find` and change a whole place in a single call.
192
+
193
+ What that buys beyond speed:
194
+
195
+ - **One Ctrl+Z.** The batch runs inside a single `ChangeHistoryService`
196
+ recording named after the tool. Ten script edits undo as one step, not ten.
197
+ - **All-or-nothing.** Everything is read and transformed in memory before
198
+ anything is written, so a find that matches nothing — or matches twice —
199
+ fails with the place untouched instead of half-edited.
200
+ - **Nesting.** `create` takes `children`, so a whole model arrives in one call.
201
+ A new instance's path is not knowable until it exists, and same-named siblings
202
+ make guessing it unreliable.
203
+
204
+ Parallel tool calls work too — responses are keyed by request id, so several
205
+ calls in one turn run concurrently. Prefer a batch where one exists: parallel
206
+ calls are N round trips and N undo steps, a batch is one of each.
163
207
 
164
208
  ## Compared to what else exists
165
209
 
@@ -173,7 +217,7 @@ Gotchas: `screenshot` doesn't work mid-playtest. `debug` needs **API debugger Lu
173
217
  | [Roblox/studio-rust-mcp-server](https://github.com/Roblox/studio-rust-mcp-server) | 2 | HTTP | no | no | no | MIT (superseded) |
174
218
 
175
219
  - **Push, not poll.** SSE stream instead of a 500 ms poll. 50 sequential round trips: **13.6 ms** mean vs 25.8 ms, **12.8 ms** median vs 29.9 ms. Reproduce with `node scripts/latency.mjs --count 50 --compare`.
176
- - **Safe script edits.** `ScriptEditorService:UpdateSourceAsync`, not `script.Source` — your unsaved editor buffer survives. Batches are all-or-nothing.
220
+ - **Safe script edits.** `ScriptEditorService:UpdateSourceAsync`, not `script.Source` — your unsaved editor buffer survives.
177
221
  - **One Ctrl+Z per action**, via `ChangeHistoryService`.
178
222
  - **29 tools, ~16k tokens of schema**, against 43–51 elsewhere. Cursor-paged, capped, with `detail: concise | standard | full`.
179
223
  - **Live API dump** for property validation, so typos get suggestions (`Anchorred` → `Anchored`).
@@ -0,0 +1,34 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * The bridge as seen from the process that actually holds the port.
4
+ *
5
+ * Carries a client id like any other, rather than being privileged as "the"
6
+ * client. The process holding the port is still just one agent among however
7
+ * many are connected, and its chosen Studio has no more right to leak into
8
+ * everyone else's calls than a proxying peer's would.
9
+ */
10
+ export class LocalBridge {
11
+ inner;
12
+ isOwner = true;
13
+ clientId = randomUUID();
14
+ constructor(inner) {
15
+ this.inner = inner;
16
+ }
17
+ call(op, params = {}, options = {}) {
18
+ return this.inner.call(op, params, { ...options, clientId: this.clientId });
19
+ }
20
+ async sessions() {
21
+ return {
22
+ list: this.inner.list(),
23
+ activeId: this.inner.activeId(this.clientId),
24
+ activeIsChosen: this.inner.activeIsChosen(this.clientId),
25
+ };
26
+ }
27
+ async setActive(studioId) {
28
+ this.inner.setActive(this.clientId, studioId);
29
+ }
30
+ async notePlaceName(studioId, placeName, context) {
31
+ this.inner.notePlaceName(studioId, placeName, context);
32
+ }
33
+ }
34
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/bridge/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA2CzC;;;;;;;GAOG;AACH,MAAM,OAAO,WAAW;IAIO,KAAK;IAHzB,OAAO,GAAG,IAAI,CAAC;IACP,QAAQ,GAAG,UAAU,EAAE,CAAC;IAEzC,YAA6B,KAAa;qBAAb,KAAK;IAAW,CAAC;IAE9C,IAAI,CACF,EAAU,EACV,MAAM,GAA4B,EAAE,EACpC,OAAO,GAA8C,EAAE;QAEvD,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAI,EAAE,EAAE,MAAM,EAAE,EAAE,GAAG,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IACjF,CAAC;IAED,KAAK,CAAC,QAAQ;QACZ,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE;YACvB,QAAQ,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC;YAC5C,cAAc,EAAE,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,IAAI,CAAC,QAAQ,CAAC;SACzD,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,QAAgB;QAC9B,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAChD,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,QAAgB,EAAE,SAAiB,EAAE,OAAgB;QACvE,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,QAAQ,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;IACzD,CAAC;CACF"}
@@ -0,0 +1,122 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CLIENT_HEADER, PROTOCOL_VERSION } from "../lib/protocol.js";
3
+ import { ToolError } from "../lib/errors.js";
4
+ /**
5
+ * Names this process to the owner, so its chosen Studio stays its own.
6
+ *
7
+ * Generated once per process and sent on every request. The owner keys each
8
+ * client's set_active_studio choice on it; without it, agents sharing a bridge
9
+ * would share one target and silently retarget each other.
10
+ */
11
+ export const PEER_HEADER = "x-roblox-studio-mcp-peer";
12
+ /**
13
+ * Asks whatever holds `port` whether it is another copy of this server.
14
+ *
15
+ * Deliberately narrow. Something else on 44755 is a reason to fail with the
16
+ * old "port is in use" message, not to start posting Luau at it, so this
17
+ * returns null on anything that is not an exact match — wrong shape, wrong
18
+ * protocol version, no answer at all.
19
+ */
20
+ export async function probeOwner(port) {
21
+ try {
22
+ const response = await fetch(`http://127.0.0.1:${port}/identity`, {
23
+ headers: { [CLIENT_HEADER]: "peer" },
24
+ signal: AbortSignal.timeout(2_000),
25
+ });
26
+ if (!response.ok)
27
+ return null;
28
+ const body = (await response.json());
29
+ if (body.server !== "roblox-studio-mcp")
30
+ return null;
31
+ if (body.protocolVersion !== PROTOCOL_VERSION)
32
+ return null;
33
+ return body;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * A bridge that forwards everything to the process holding the port.
41
+ *
42
+ * There is no second connection to Studio and no second copy of the in-flight
43
+ * table: the owner does the work and this waits for the answer, so two agents
44
+ * calling at once are simply two commands on the owner's queue, and the undo
45
+ * recording each one opens still belongs to whichever tool made it.
46
+ */
47
+ export class RemoteBridge {
48
+ port;
49
+ owner;
50
+ isOwner = false;
51
+ clientId = randomUUID();
52
+ constructor(port, owner) {
53
+ this.port = port;
54
+ this.owner = owner;
55
+ }
56
+ get base() {
57
+ return `http://127.0.0.1:${this.port}`;
58
+ }
59
+ get headers() {
60
+ return {
61
+ [CLIENT_HEADER]: "peer",
62
+ [PEER_HEADER]: this.clientId,
63
+ "Content-Type": "application/json",
64
+ };
65
+ }
66
+ async post(path, body, timeoutMs) {
67
+ let response;
68
+ try {
69
+ response = await fetch(`${this.base}${path}`, {
70
+ method: "POST",
71
+ headers: this.headers,
72
+ body: JSON.stringify(body),
73
+ // Padded past the command's own deadline so the owner's timeout wins and
74
+ // its diagnosis reaches the caller, rather than being cut off by ours.
75
+ signal: AbortSignal.timeout(timeoutMs + 10_000),
76
+ });
77
+ }
78
+ catch (cause) {
79
+ throw this.unreachable(cause);
80
+ }
81
+ const payload = (await response.json());
82
+ if (!payload.ok) {
83
+ const error = payload.error ?? { code: "PEER_ERROR", message: "the bridge owner refused" };
84
+ throw new ToolError(error.code, error.message);
85
+ }
86
+ return payload.data;
87
+ }
88
+ unreachable(cause) {
89
+ return new ToolError("OWNER_GONE", `The roblox-studio-mcp instance holding port ${this.port} (pid ${this.owner.pid}) ` +
90
+ `stopped answering: ${cause instanceof Error ? cause.message : String(cause)}`, "That process owns the Studio connection and this one borrows it. It has most " +
91
+ "likely exited — restart this MCP server and it will take the port itself.");
92
+ }
93
+ call(op, params = {}, options = {}) {
94
+ return this.post("/call", { op, params, ...options }, options.timeoutMs ?? 15_000);
95
+ }
96
+ async sessions() {
97
+ try {
98
+ const response = await fetch(`${this.base}/sessions`, {
99
+ headers: this.headers,
100
+ signal: AbortSignal.timeout(5_000),
101
+ });
102
+ return (await response.json());
103
+ }
104
+ catch (cause) {
105
+ throw this.unreachable(cause);
106
+ }
107
+ }
108
+ async setActive(studioId) {
109
+ await this.post("/active", { studioId }, 5_000);
110
+ }
111
+ async notePlaceName(studioId, placeName, context) {
112
+ // Best effort: this only refreshes a display name on the owner, and losing
113
+ // it should never fail the call that happened to learn it.
114
+ try {
115
+ await this.post("/place-name", { studioId, placeName, context }, 5_000);
116
+ }
117
+ catch {
118
+ /* ignored */
119
+ }
120
+ }
121
+ }
122
+ //# sourceMappingURL=remote.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote.js","sourceRoot":"","sources":["../../src/bridge/remote.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { AMBIGUOUS_STUDIO, DISCONNECTED, NO_STUDIO, TIMEOUT, ToolError, } from "../lib/errors.js";
2
+ import { AMBIGUOUS_STUDIO, DISCONNECTED, NO_STUDIO, SAME_PLACE_STUDIO, TIMEOUT, ToolError, } from "../lib/errors.js";
3
3
  /** Default per-command deadline. Studio round trips are single-digit ms over SSE. */
4
4
  export const DEFAULT_TIMEOUT_MS = 15_000;
5
5
  /** How long a long-poll request is parked before we answer "idle". */
@@ -15,17 +15,24 @@ const STALE_AFTER_MS = 90_000;
15
15
  */
16
16
  export class Bridge {
17
17
  sessions = new Map();
18
- activeStudioId = null;
19
18
  /**
20
- * Whether `activeStudioId` was picked by the user via set_active_studio, as
21
- * opposed to being the only session that happened to connect first.
19
+ * Which Studio each connected client chose, keyed by client.
22
20
  *
23
- * The distinction decides what happens when a second Studio appears. A
24
- * defaulted target silently becomes ambiguous — editing whichever place
25
- * connected first is not a reasonable guess — while a chosen one stays put,
26
- * because the user said which place they meant.
21
+ * Per client, not per process, and that is the whole point. Any number of
22
+ * agents share this bridge — the first server to start owns the port and the
23
+ * rest proxy through it — so a single chosen target would be shared mutable
24
+ * state between agents that cannot see each other: one calling
25
+ * set_active_studio would silently retarget the next un-addressed call of
26
+ * every other. Editing the wrong place is not a failure that announces
27
+ * itself, so the answer is isolation rather than a warning.
28
+ *
29
+ * Presence in the map is what "chosen" means. That distinction decides what
30
+ * happens when a second Studio appears: a client that never chose goes
31
+ * ambiguous, because editing whichever place connected first is not a
32
+ * reasonable guess, while one that did stays put, because it said what it
33
+ * meant.
27
34
  */
28
- activeWasChosen = false;
35
+ chosen = new Map();
29
36
  // --- session lifecycle -------------------------------------------------
30
37
  attach(identity, stream) {
31
38
  const existing = this.sessions.get(identity.studioId);
@@ -48,7 +55,6 @@ export class Bridge {
48
55
  waiter: null,
49
56
  pending: new Map(),
50
57
  });
51
- this.activeStudioId ??= identity.studioId;
52
58
  return identity.studioId;
53
59
  }
54
60
  detach(studioId) {
@@ -63,11 +69,13 @@ export class Bridge {
63
69
  session.waiter?.(null);
64
70
  session.stream?.end();
65
71
  this.sessions.delete(studioId);
66
- if (this.activeStudioId === studioId) {
67
- // The place the user chose is gone, so the replacement is a fallback, not
68
- // a choice: mark it as such so a remaining pair goes ambiguous again.
69
- this.activeStudioId = this.sessions.keys().next().value ?? null;
70
- this.activeWasChosen = false;
72
+ // A choice that no longer exists is not a choice. Dropping it rather than
73
+ // substituting a survivor is deliberate: with a pair left, going ambiguous
74
+ // asks the question again, where quietly promoting whichever remains would
75
+ // point the agent at a place nobody picked.
76
+ for (const [clientId, chosenId] of this.chosen) {
77
+ if (chosenId === studioId)
78
+ this.chosen.delete(clientId);
71
79
  }
72
80
  }
73
81
  /** Drops sessions whose plugin stopped checking in without a clean detach. */
@@ -110,19 +118,34 @@ export class Bridge {
110
118
  lastSeenAt: session.lastSeenAt,
111
119
  }));
112
120
  }
113
- get activeId() {
114
- return this.activeStudioId;
121
+ /**
122
+ * What this client's calls target when they name no studioId.
123
+ *
124
+ * A lone Studio is reported as the target whether or not anyone picked it,
125
+ * because with one connected there is nothing else a call could mean.
126
+ */
127
+ activeId(clientId) {
128
+ const picked = this.chosen.get(clientId);
129
+ if (picked !== undefined && this.sessions.has(picked))
130
+ return picked;
131
+ if (this.sessions.size === 1)
132
+ return this.sessions.keys().next().value ?? null;
133
+ return null;
115
134
  }
116
- /** True only once the user has actually picked a target. */
117
- get activeIsChosen() {
118
- return this.activeWasChosen;
135
+ /** True only once this client has actually picked a target. */
136
+ activeIsChosen(clientId) {
137
+ const picked = this.chosen.get(clientId);
138
+ return picked !== undefined && this.sessions.has(picked);
119
139
  }
120
- setActive(studioId) {
140
+ setActive(clientId, studioId) {
121
141
  if (!this.sessions.has(studioId)) {
122
142
  throw new ToolError("UNKNOWN_STUDIO", `No connected Studio has id "${studioId}".`, "Call list_studios to see the connected instances and their ids.");
123
143
  }
124
- this.activeStudioId = studioId;
125
- this.activeWasChosen = true;
144
+ this.chosen.set(clientId, studioId);
145
+ }
146
+ /** Forgets a client's choice when its server instance goes away. */
147
+ forgetClient(clientId) {
148
+ this.chosen.delete(clientId);
126
149
  }
127
150
  // --- request/response --------------------------------------------------
128
151
  /**
@@ -130,14 +153,24 @@ export class Bridge {
130
153
  * Rejects with a ToolError carrying an agent-readable hint on any failure,
131
154
  * including errors raised inside the plugin.
132
155
  */
133
- call(op, params = {}, options = {}) {
134
- const session = this.resolveSession(options.studioId);
156
+ call(op, params = {}, options) {
157
+ const session = this.resolveSession(options.clientId, options.studioId);
135
158
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
136
159
  const command = { id: randomUUID(), op, params };
137
160
  return new Promise((resolve, reject) => {
138
161
  const timer = setTimeout(() => {
139
162
  session.pending.delete(command.id);
140
- reject(TIMEOUT(op, timeoutMs));
163
+ // A bare "it timed out" leaves nowhere to go, and the interesting part
164
+ // is knowable here: whether the command ever left this process, and
165
+ // whether the plugin has said anything since. A command still sitting in
166
+ // the queue was never seen by Studio, which is a different fault from
167
+ // one Studio took and did not finish.
168
+ reject(TIMEOUT(op, timeoutMs, {
169
+ delivered: session.queue.every((queued) => queued.id !== command.id),
170
+ silentForMs: Date.now() - session.lastSeenAt,
171
+ alsoInFlight: session.pending.size,
172
+ transport: session.stream ? "sse" : "poll",
173
+ }));
141
174
  }, timeoutMs);
142
175
  session.pending.set(command.id, {
143
176
  resolve: resolve,
@@ -196,7 +229,7 @@ export class Bridge {
196
229
  });
197
230
  }
198
231
  // --- internals ---------------------------------------------------------
199
- resolveSession(studioId) {
232
+ resolveSession(clientId, studioId) {
200
233
  if (studioId) {
201
234
  const session = this.sessions.get(studioId);
202
235
  if (!session)
@@ -209,16 +242,27 @@ export class Bridge {
209
242
  const only = this.sessions.values().next().value;
210
243
  if (this.sessions.size === 1 && only)
211
244
  return only;
212
- if (this.activeWasChosen && this.activeStudioId) {
213
- const session = this.sessions.get(this.activeStudioId);
245
+ const picked = this.chosen.get(clientId);
246
+ if (picked !== undefined) {
247
+ const session = this.sessions.get(picked);
214
248
  if (session)
215
249
  return session;
216
250
  }
217
- throw AMBIGUOUS_STUDIO(
251
+ const connected = this.list();
218
252
  // The context is the part that decides it. Two rows reading "Untitled
219
253
  // Experience" are indistinguishable, and picking the playtest means work
220
254
  // that disappears when it stops.
221
- this.list().map((session) => `${session.studioId} (${session.placeName}${session.context ? `, ${session.context}` : ""})`));
255
+ const rows = connected.map((session) => `${session.studioId} (${session.placeName}${session.context ? `, ${session.context}` : ""})`);
256
+ // A playtest is not a second place. Starting one connects a second session
257
+ // on the same placeId, and telling the agent to go and ask which place the
258
+ // user means is then a question with no answer -- there is one place, in two
259
+ // states, and which to use follows from what is being asked rather than from
260
+ // anything the user knows.
261
+ const places = new Set(connected.map((session) => session.placeId));
262
+ if (places.size === 1 && connected.length > 1) {
263
+ throw SAME_PLACE_STUDIO(rows);
264
+ }
265
+ throw AMBIGUOUS_STUDIO(rows);
222
266
  }
223
267
  deliver(session, command) {
224
268
  if (session.stream && !session.stream.writableEnded) {
@@ -1 +1 @@
1
- {"version":3,"file":"rpc.js","sourceRoot":"","sources":["../../src/bridge/rpc.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"rpc.js","sourceRoot":"","sources":["../../src/bridge/rpc.ts"],"names":[],"mappings":"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"}
@@ -1,12 +1,20 @@
1
1
  import { createServer } from "node:http";
2
2
  import { CLIENT_HEADER, PROTOCOL_VERSION } from "../lib/protocol.js";
3
+ import { PEER_HEADER } from "./remote.js";
4
+ import { toToolError } from "../lib/errors.js";
3
5
  import { Bridge } from "./rpc.js";
6
+ import { LocalBridge } from "./api.js";
7
+ import { probeOwner, RemoteBridge } from "./remote.js";
4
8
  /** Default loopback port. Deliberately not 58741 — that is drgost1's server. */
5
9
  export const DEFAULT_PORT = 44755;
6
10
  /** SSE comment cadence. Keeps intermediaries and Studio from reaping an idle stream. */
7
11
  const HEARTBEAT_MS = 15_000;
8
12
  /** Largest body we accept from the plugin (script sources and screenshots are big). */
9
13
  const MAX_BODY_BYTES = 32 * 1024 * 1024;
14
+ // The bridge's own diagnostic calls. They always name a studioId outright, so
15
+ // this never resolves anything -- it exists so they cannot inherit, or become,
16
+ // some agent's chosen target.
17
+ const INTERNAL_CLIENT = "bridge-internal";
10
18
  /**
11
19
  * Starts the loopback HTTP endpoint the Studio plugin talks to.
12
20
  *
@@ -38,9 +46,25 @@ export function startBridgeServer(options = {}) {
38
46
  // fight over the plugin's connection, so say so plainly instead of
39
47
  // surfacing a bare EADDRINUSE.
40
48
  if (cause.code === "EADDRINUSE") {
41
- reject(new Error(`Port ${port} is already in use — another roblox-studio-mcp is probably ` +
42
- `running. Stop it, or start this one with --port <other> and set the ` +
43
- `matching port in the Studio plugin widget.`));
49
+ // Almost always another copy of this server, which is not a failure:
50
+ // the plugin can only connect to one port, so the right answer is to
51
+ // share that one connection rather than fight for it. Any number of MCP
52
+ // clients can then drive one Studio.
53
+ void probeOwner(port).then((existing) => {
54
+ if (existing) {
55
+ clearInterval(reaper);
56
+ resolve({
57
+ bridge: new RemoteBridge(port, existing),
58
+ port,
59
+ owner: false,
60
+ close: async () => clearInterval(reaper),
61
+ });
62
+ return;
63
+ }
64
+ reject(new Error(`Port ${port} is in use by something that is not roblox-studio-mcp. ` +
65
+ `Stop it, or start this one with --port <other> and set the matching ` +
66
+ `port in the Studio plugin widget.`));
67
+ });
44
68
  return;
45
69
  }
46
70
  reject(cause);
@@ -49,8 +73,9 @@ export function startBridgeServer(options = {}) {
49
73
  server.listen(port, "127.0.0.1", () => {
50
74
  server.removeListener("error", reject);
51
75
  resolve({
52
- bridge,
76
+ bridge: new LocalBridge(bridge),
53
77
  port,
78
+ owner: true,
54
79
  close: () => closeServer(server, reaper),
55
80
  });
56
81
  });
@@ -87,6 +112,28 @@ async function handle(bridge, req, res) {
87
112
  return await handleLatency(bridge, url, res);
88
113
  case "POST /transport":
89
114
  return await handleTransport(bridge, url, res);
115
+ case "GET /identity":
116
+ // Answered so a second instance can tell us apart from whatever else
117
+ // might be squatting on the port. See probeOwner.
118
+ return send(res, 200, {
119
+ server: "roblox-studio-mcp",
120
+ protocolVersion: PROTOCOL_VERSION,
121
+ pid: process.pid,
122
+ });
123
+ case "POST /call":
124
+ return await handlePeerCall(bridge, req, res);
125
+ case "GET /sessions": {
126
+ const clientId = peerId(req);
127
+ return send(res, 200, {
128
+ list: bridge.list(),
129
+ activeId: bridge.activeId(clientId),
130
+ activeIsChosen: bridge.activeIsChosen(clientId),
131
+ });
132
+ }
133
+ case "POST /active":
134
+ return await handlePeerActive(bridge, req, res);
135
+ case "POST /place-name":
136
+ return await handlePeerPlaceName(bridge, req, res);
90
137
  case "POST /bye":
91
138
  bridge.detach(url.searchParams.get("studioId") ?? "");
92
139
  return send(res, 200, { ok: true });
@@ -136,11 +183,11 @@ async function handleLatency(bridge, url, res) {
136
183
  }
137
184
  // One warm-up that is not recorded: the first call after an idle period pays
138
185
  // for stream wake-up, which is real but is not what the tenth call costs.
139
- await bridge.call("studio.ping", {}, { studioId: target.studioId });
186
+ await bridge.call("studio.ping", {}, { clientId: INTERNAL_CLIENT, studioId: target.studioId });
140
187
  const samples = [];
141
188
  for (let index = 0; index < count; index += 1) {
142
189
  const started = process.hrtime.bigint();
143
- await bridge.call("studio.ping", {}, { studioId: target.studioId });
190
+ await bridge.call("studio.ping", {}, { clientId: INTERNAL_CLIENT, studioId: target.studioId });
144
191
  samples.push(Number(process.hrtime.bigint() - started) / 1e6);
145
192
  }
146
193
  const sorted = [...samples].sort((a, b) => a - b);
@@ -175,7 +222,7 @@ async function handleTransport(bridge, url, res) {
175
222
  if (target === undefined) {
176
223
  return send(res, 409, { error: "No Studio is connected." });
177
224
  }
178
- const result = await bridge.call("studio.transport", { mode }, { studioId: target.studioId });
225
+ const result = await bridge.call("studio.transport", { mode }, { clientId: INTERNAL_CLIENT, studioId: target.studioId });
179
226
  send(res, 200, result);
180
227
  }
181
228
  async function handleConnect(bridge, req, res) {
@@ -278,4 +325,57 @@ function send(res, status, body) {
278
325
  });
279
326
  res.end(payload);
280
327
  }
328
+ /**
329
+ * Runs a command on behalf of another instance of this server.
330
+ *
331
+ * The failure is returned as a body rather than an HTTP status, because the
332
+ * code and hint are the useful part and a 500 would throw them away — the peer
333
+ * rebuilds a ToolError from this and the agent sees the same text it would have
334
+ * seen had this process been the one it was talking to.
335
+ */
336
+ /**
337
+ * Which peer is asking, so its chosen Studio does not become everyone's.
338
+ *
339
+ * A peer that sends no id is treated as one anonymous client rather than
340
+ * rejected: an older instance proxying to a newer one still works, it simply
341
+ * shares a target with any other peer that also predates the header.
342
+ */
343
+ function peerId(req) {
344
+ const sent = req.headers[PEER_HEADER];
345
+ const value = Array.isArray(sent) ? sent[0] : sent;
346
+ return value && value.length > 0 ? value : "anonymous-peer";
347
+ }
348
+ async function handlePeerCall(bridge, req, res) {
349
+ const body = JSON.parse(await readBody(req));
350
+ if (typeof body.op !== "string")
351
+ return send(res, 400, { ok: false, error: { code: "BAD_PEER_CALL", message: "no op" } });
352
+ try {
353
+ const data = await bridge.call(body.op, body.params ?? {}, {
354
+ clientId: peerId(req),
355
+ studioId: body.studioId,
356
+ timeoutMs: body.timeoutMs,
357
+ });
358
+ return send(res, 200, { ok: true, data });
359
+ }
360
+ catch (cause) {
361
+ const error = toToolError(cause);
362
+ return send(res, 200, { ok: false, error: { code: error.code, message: error.message } });
363
+ }
364
+ }
365
+ async function handlePeerActive(bridge, req, res) {
366
+ const body = JSON.parse(await readBody(req));
367
+ try {
368
+ bridge.setActive(peerId(req), String(body.studioId ?? ""));
369
+ return send(res, 200, { ok: true });
370
+ }
371
+ catch (cause) {
372
+ const error = toToolError(cause);
373
+ return send(res, 200, { ok: false, error: { code: error.code, message: error.message } });
374
+ }
375
+ }
376
+ async function handlePeerPlaceName(bridge, req, res) {
377
+ const body = JSON.parse(await readBody(req));
378
+ bridge.notePlaceName(String(body.studioId ?? ""), String(body.placeName ?? ""), body.context);
379
+ return send(res, 200, { ok: true });
380
+ }
281
381
  //# sourceMappingURL=server.js.map