@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 +48 -4
- package/dist/bridge/api.js +34 -0
- package/dist/bridge/api.js.map +1 -0
- package/dist/bridge/remote.js +122 -0
- package/dist/bridge/remote.js.map +1 -0
- package/dist/bridge/rpc.js +75 -31
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +107 -7
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/errors.js +48 -3
- package/dist/lib/errors.js.map +1 -1
- package/dist/tools/input.js +13 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +3 -3
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/screenshot.js +46 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/session.js +14 -9
- package/dist/tools/session.js.map +1 -1
- package/package.json +62 -62
- package/plugin/src/Config.luau +59 -59
- package/plugin/src/handlers/Capture.luau +366 -187
- package/plugin/src/handlers/Debug.luau +2 -1
- package/plugin/src/handlers/Input.luau +166 -0
- package/scripts/prism.mjs +310 -0
- package/scripts/test-bridge.mjs +107 -0
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
|
+

|
|
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/
|
|
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
|
+

|
|
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: `
|
|
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.
|
|
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":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAU7C;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,0BAA0B,CAAC;AAEtD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY;IAC3C,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,oBAAoB,IAAI,WAAW,EAAE;YAChE,OAAO,EAAE,EAAE,CAAC,aAAa,CAAC,EAAE,MAAM,EAAE;YACpC,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,KAAK,CAAC;SACnC,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,OAAO,IAAI,CAAC;QAC9B,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA2B,CAAC;QAC/D,IAAI,IAAI,CAAC,MAAM,KAAK,mBAAmB;YAAE,OAAO,IAAI,CAAC;QACrD,IAAI,IAAI,CAAC,eAAe,KAAK,gBAAgB;YAAE,OAAO,IAAI,CAAC;QAC3D,OAAO,IAAqB,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,YAAY;IAKJ,IAAI;IACZ,KAAK;IALP,OAAO,GAAG,KAAK,CAAC;IACR,QAAQ,GAAG,UAAU,EAAE,CAAC;IAEzC,YACmB,IAAY,EACpB,KAAoB;oBADZ,IAAI;qBACZ,KAAK;IACb,CAAC;IAEJ,IAAY,IAAI;QACd,OAAO,oBAAoB,IAAI,CAAC,IAAI,EAAE,CAAC;IACzC,CAAC;IAED,IAAY,OAAO;QACjB,OAAO;YACL,CAAC,aAAa,CAAC,EAAE,MAAM;YACvB,CAAC,WAAW,CAAC,EAAE,IAAI,CAAC,QAAQ;YAC5B,cAAc,EAAE,kBAAkB;SACnC,CAAC;IACJ,CAAC;IAEO,KAAK,CAAC,IAAI,CAAI,IAAY,EAAE,IAAa,EAAE,SAAiB;QAClE,IAAI,QAAkB,CAAC;QACvB,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,IAAI,GAAG,IAAI,EAAE,EAAE;gBAC5C,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;gBAC1B,yEAAyE;gBACzE,uEAAuE;gBACvE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,SAAS,GAAG,MAAM,CAAC;aAChD,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;QAChC,CAAC;QAED,MAAM,OAAO,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAIrC,CAAC;QACF,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC;YAChB,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,0BAA0B,EAAE,CAAC;YAC3F,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;QACjD,CAAC;QACD,OAAO,OAAO,CAAC,IAAS,CAAC;IAC3B,CAAC;IAEO,WAAW,CAAC,KAAc;QAChC,OAAO,IAAI,SAAS,CAClB,YAAY,EACZ,+CAA+C,IAAI,CAAC,IAAI,SAAS,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI;YACjF,sBAAsB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAChF,+EAA+E;YAC7E,2EAA2E,CAC9E,CAAC;IACJ,CAAC;IAED,IAAI,CACF,EAAU,EACV,MAAM,GAA4B,EAAE,EACpC,OAAO,GAA8C,EAAE;QAEvD,OAAO,IAAI,CAAC,IAAI,CAAI,OAAO,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,OAAO,EAAE,EAAE,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC,CAAC;IACxF,CAAC;IAED,KAAK,CAAC,QAAQ;QACZ,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,CAAC,IAAI,WAAW,EAAE;gBACpD,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,KAAK,CAAC;aACnC,CAAC,CAAC;YACH,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAiB,CAAC;QACjD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;QAChC,CAAC;IACH,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,QAAgB;QAC9B,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,QAAQ,EAAE,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,QAAgB,EAAE,SAAiB,EAAE,OAAgB;QACvE,2EAA2E;QAC3E,2DAA2D;QAC3D,IAAI,CAAC;YACH,MAAM,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE,KAAK,CAAC,CAAC;QAC1E,CAAC;QAAC,MAAM,CAAC;YACP,aAAa;QACf,CAAC;IACH,CAAC;CACF"}
|
package/dist/bridge/rpc.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/bridge/rpc.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpc.js","sourceRoot":"","sources":["../../src/bridge/rpc.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,SAAS,EACT,OAAO,EACP,SAAS,GACV,MAAM,kBAAkB,CAAC;AAQ1B,qFAAqF;AACrF,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC;AAEzC,sEAAsE;AACtE,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC;AAEnC,8EAA8E;AAC9E,MAAM,cAAc,GAAG,MAAM,CAAC;AAuB9B;;;;;;GAMG;AACH,MAAM,OAAO,MAAM;IACA,QAAQ,GAAG,IAAI,GAAG,EAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"rpc.js","sourceRoot":"","sources":["../../src/bridge/rpc.ts"],"names":[],"mappings":"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"}
|
package/dist/bridge/server.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|