@quenty/studio-bridge 0.23.0 → 0.24.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.
Files changed (231) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +36 -8
  3. package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
  4. package/dist/src/bridge/bridge-connection.js +1 -0
  5. package/dist/src/bridge/bridge-connection.js.map +1 -1
  6. package/dist/src/bridge/bridge-session.d.ts +11 -16
  7. package/dist/src/bridge/bridge-session.d.ts.map +1 -1
  8. package/dist/src/bridge/bridge-session.js +37 -29
  9. package/dist/src/bridge/bridge-session.js.map +1 -1
  10. package/dist/src/bridge/bridge-session.test.js +35 -0
  11. package/dist/src/bridge/bridge-session.test.js.map +1 -1
  12. package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
  13. package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
  14. package/dist/src/bridge/internal/bridge-host.js +1 -0
  15. package/dist/src/bridge/internal/bridge-host.js.map +1 -1
  16. package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
  17. package/dist/src/bridge/internal/host-protocol.js +23 -10
  18. package/dist/src/bridge/internal/host-protocol.js.map +1 -1
  19. package/dist/src/bridge/types.d.ts +5 -2
  20. package/dist/src/bridge/types.d.ts.map +1 -1
  21. package/dist/src/bridge/types.js.map +1 -1
  22. package/dist/src/commands/console/exec/exec.d.ts +13 -3
  23. package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
  24. package/dist/src/commands/console/exec/exec.js +82 -7
  25. package/dist/src/commands/console/exec/exec.js.map +1 -1
  26. package/dist/src/commands/console/exec/exec.test.js +63 -1
  27. package/dist/src/commands/console/exec/exec.test.js.map +1 -1
  28. package/dist/src/commands/framework/action-loader.test.js +1 -1
  29. package/dist/src/commands/framework/action-loader.test.js.map +1 -1
  30. package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
  31. package/dist/src/commands/playtest/player/playtest-players.js +15 -93
  32. package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
  33. package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
  34. package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
  35. package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
  36. package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
  37. package/dist/src/commands/playtest/playtest-sessions.js +54 -74
  38. package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
  39. package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
  40. package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
  41. package/dist/src/commands/playtest/run/run.d.ts +9 -7
  42. package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
  43. package/dist/src/commands/playtest/run/run.js +33 -38
  44. package/dist/src/commands/playtest/run/run.js.map +1 -1
  45. package/dist/src/commands/process/close/close.d.ts +4 -0
  46. package/dist/src/commands/process/close/close.d.ts.map +1 -1
  47. package/dist/src/commands/process/close/close.js +54 -4
  48. package/dist/src/commands/process/close/close.js.map +1 -1
  49. package/dist/src/commands/process/close/close.test.js +36 -3
  50. package/dist/src/commands/process/close/close.test.js.map +1 -1
  51. package/dist/src/commands/process/info/info.d.ts +15 -2
  52. package/dist/src/commands/process/info/info.d.ts.map +1 -1
  53. package/dist/src/commands/process/info/info.js +44 -4
  54. package/dist/src/commands/process/info/info.js.map +1 -1
  55. package/dist/src/commands/process/info/info.test.js +48 -0
  56. package/dist/src/commands/process/info/info.test.js.map +1 -1
  57. package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
  58. package/dist/src/commands/process/launch/launch.js +22 -3
  59. package/dist/src/commands/process/launch/launch.js.map +1 -1
  60. package/dist/src/commands/process/launch/launch.test.js +27 -5
  61. package/dist/src/commands/process/launch/launch.test.js.map +1 -1
  62. package/dist/src/commands/process/list/list.d.ts +49 -6
  63. package/dist/src/commands/process/list/list.d.ts.map +1 -1
  64. package/dist/src/commands/process/list/list.js +185 -18
  65. package/dist/src/commands/process/list/list.js.map +1 -1
  66. package/dist/src/commands/process/list/list.test.js +92 -1
  67. package/dist/src/commands/process/list/list.test.js.map +1 -1
  68. package/dist/src/commands/process/run/run.d.ts +1 -1
  69. package/dist/src/commands/process/run/run.d.ts.map +1 -1
  70. package/dist/src/commands/process/run/run.js +3 -3
  71. package/dist/src/commands/process/run/run.js.map +1 -1
  72. package/dist/src/docker/docker-delegator.js +1 -1
  73. package/dist/src/docker/docker-delegator.js.map +1 -1
  74. package/dist/src/docker/docker-delegator.test.js +1 -1
  75. package/dist/src/docker/docker-delegator.test.js.map +1 -1
  76. package/dist/src/plugin/playtest-bridge.d.ts +36 -0
  77. package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
  78. package/dist/src/plugin/playtest-bridge.js +65 -0
  79. package/dist/src/plugin/playtest-bridge.js.map +1 -0
  80. package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
  81. package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
  82. package/dist/src/plugin/playtest-bridge.test.js +35 -0
  83. package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
  84. package/dist/src/process/studio-launch-registry.d.ts +14 -0
  85. package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
  86. package/dist/src/process/studio-launch-registry.js +37 -0
  87. package/dist/src/process/studio-launch-registry.js.map +1 -0
  88. package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
  89. package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
  90. package/dist/src/process/studio-launch-registry.test.js +38 -0
  91. package/dist/src/process/studio-launch-registry.test.js.map +1 -0
  92. package/dist/src/process/studio-process-closer.d.ts +3 -1
  93. package/dist/src/process/studio-process-closer.d.ts.map +1 -1
  94. package/dist/src/process/studio-process-closer.js +29 -11
  95. package/dist/src/process/studio-process-closer.js.map +1 -1
  96. package/dist/src/process/studio-process-identity.d.ts +10 -0
  97. package/dist/src/process/studio-process-identity.d.ts.map +1 -0
  98. package/dist/src/process/studio-process-identity.js +63 -0
  99. package/dist/src/process/studio-process-identity.js.map +1 -0
  100. package/dist/src/process/studio-process-identity.test.d.ts +2 -0
  101. package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
  102. package/dist/src/process/studio-process-identity.test.js +30 -0
  103. package/dist/src/process/studio-process-identity.test.js.map +1 -0
  104. package/dist/src/roblox-mcp/index.d.ts +3 -0
  105. package/dist/src/roblox-mcp/index.d.ts.map +1 -0
  106. package/dist/src/roblox-mcp/index.js +3 -0
  107. package/dist/src/roblox-mcp/index.js.map +1 -0
  108. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
  109. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
  110. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
  111. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
  112. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
  113. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
  114. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
  115. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
  116. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
  117. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
  118. package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
  119. package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
  120. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
  121. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
  122. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
  123. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
  124. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
  125. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
  126. package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
  127. package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
  128. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
  129. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
  130. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
  131. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
  132. package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
  133. package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
  134. package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
  135. package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
  136. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
  137. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
  138. package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
  139. package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
  140. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
  141. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
  142. package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
  143. package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
  144. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
  145. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
  146. package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
  147. package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
  148. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
  149. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
  150. package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
  151. package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
  152. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
  153. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
  154. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
  155. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
  156. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
  157. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
  158. package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
  159. package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
  160. package/dist/src/server/web-socket-protocol.d.ts +50 -15
  161. package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
  162. package/dist/src/server/web-socket-protocol.js +63 -13
  163. package/dist/src/server/web-socket-protocol.js.map +1 -1
  164. package/dist/tsconfig.tsbuildinfo +1 -1
  165. package/docs/playtest-bridge.md +21 -0
  166. package/package.json +2 -2
  167. package/src/bridge/bridge-connection.ts +1 -0
  168. package/src/bridge/bridge-session.test.ts +51 -0
  169. package/src/bridge/bridge-session.ts +66 -47
  170. package/src/bridge/internal/bridge-host.ts +3 -0
  171. package/src/bridge/internal/host-protocol.ts +22 -9
  172. package/src/bridge/types.ts +5 -0
  173. package/src/commands/console/exec/exec.test.ts +94 -0
  174. package/src/commands/console/exec/exec.ts +130 -16
  175. package/src/commands/console/exec/execute.lua +147 -5
  176. package/src/commands/console/exec/execute.spec.lua +22 -2
  177. package/src/commands/framework/action-loader.test.ts +1 -1
  178. package/src/commands/playtest/player/playtest-players.lua +127 -0
  179. package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
  180. package/src/commands/playtest/player/playtest-players.test.ts +40 -21
  181. package/src/commands/playtest/player/playtest-players.ts +30 -110
  182. package/src/commands/playtest/playtest-sessions.test.ts +116 -14
  183. package/src/commands/playtest/playtest-sessions.ts +77 -88
  184. package/src/commands/playtest/run/run.ts +44 -58
  185. package/src/commands/playtest/start/start-playtest.lua +105 -6
  186. package/src/commands/process/close/close.test.ts +52 -3
  187. package/src/commands/process/close/close.ts +78 -4
  188. package/src/commands/process/info/info.test.ts +76 -0
  189. package/src/commands/process/info/info.ts +74 -6
  190. package/src/commands/process/launch/launch.test.ts +33 -5
  191. package/src/commands/process/launch/launch.ts +41 -3
  192. package/src/commands/process/list/list.test.ts +130 -1
  193. package/src/commands/process/list/list.ts +290 -22
  194. package/src/commands/process/run/run.ts +4 -4
  195. package/src/docker/docker-delegator.test.ts +1 -1
  196. package/src/docker/docker-delegator.ts +1 -1
  197. package/src/plugin/playtest-bridge.test.ts +53 -0
  198. package/src/plugin/playtest-bridge.ts +98 -0
  199. package/src/process/studio-launch-registry.test.ts +56 -0
  200. package/src/process/studio-launch-registry.ts +63 -0
  201. package/src/process/studio-process-closer.ts +36 -11
  202. package/src/process/studio-process-identity.test.ts +45 -0
  203. package/src/process/studio-process-identity.ts +90 -0
  204. package/src/roblox-mcp/index.ts +17 -0
  205. package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
  206. package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
  207. package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
  208. package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
  209. package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
  210. package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
  211. package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
  212. package/src/roblox-mcp/studio/studio-session.ts +279 -0
  213. package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
  214. package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
  215. package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
  216. package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
  217. package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
  218. package/src/server/web-socket-protocol.ts +119 -29
  219. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
  220. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
  221. package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
  222. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
  223. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
  224. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
  225. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
  226. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
  227. package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
  228. package/src/commands/playtest/run/playtest-runner.luau +0 -264
  229. package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
  230. package/src/commands/playtest/run/run-playtest-script.lua +0 -139
  231. package/src/commands/playtest/run/run-playtest-script.spec.lua +0 -43
@@ -0,0 +1,330 @@
1
+ import type { SessionContext, SessionInfo } from '../bridge/types.js';
2
+ import { listStudioProcessesAsync } from '../process/studio-process-closer.js';
3
+ import {
4
+ McpStdioClient,
5
+ McpTimeoutError,
6
+ type SpawnMcpProcess,
7
+ } from './mcp/mcp-stdio-client.js';
8
+ import {
9
+ findStudioMcpLaunchAsync,
10
+ StudioMcpUnsupportedError,
11
+ } from './studio-mcp-locator.js';
12
+ import {
13
+ cleanToolError,
14
+ DATAMODEL_TYPES,
15
+ listMcpStudiosAsync,
16
+ matchStudiosAsync,
17
+ resolveStudio,
18
+ StudioNotFoundError,
19
+ type MatchedStudio,
20
+ type McpStudio,
21
+ type StudioWindow,
22
+ } from './studio/studio-session.js';
23
+
24
+ export type StudioMcpTarget = MatchedStudio;
25
+
26
+ export type StudioMcpFailure =
27
+ | 'unsupported'
28
+ | 'unavailable'
29
+ | 'no-studio'
30
+ | 'failed'
31
+ | 'timeout';
32
+
33
+ export type StudioMcpResult<T> =
34
+ | { success: true; value: T; studio?: StudioMcpTarget; settled?: boolean }
35
+ | {
36
+ success: false;
37
+ reason: StudioMcpFailure;
38
+ message: string;
39
+ studio?: StudioMcpTarget;
40
+ };
41
+
42
+ export interface StudioMcpOptions {
43
+ target?: string;
44
+ sessions?: SessionInfo[];
45
+ timeoutMs?: number;
46
+ executablePath?: string;
47
+ spawnProcess?: SpawnMcpProcess;
48
+ listWindowsAsync?: () => Promise<StudioWindow[]>;
49
+ readPlaceIds?: boolean;
50
+ sessionPids?: Map<string, number>;
51
+ }
52
+
53
+ export interface StudioMcpLuauOptions extends StudioMcpOptions {
54
+ context?: SessionContext;
55
+ }
56
+
57
+ const DEFAULT_TIMEOUT_MS = 15_000;
58
+ const STEP_TIMEOUT_MS = 5_000;
59
+ const STUDIO_RECONNECT_MS = 4_000;
60
+ const RECONNECT_POLL_MS = 250;
61
+ const SETTLE_RESERVE_MS = 2_500;
62
+ const IDLE_CLOSE_MS = 15_000;
63
+
64
+ interface SharedClient {
65
+ client: McpStdioClient;
66
+ startedAt: number;
67
+ key: string;
68
+ spawnProcess?: SpawnMcpProcess;
69
+ idleTimer?: NodeJS.Timeout;
70
+ }
71
+
72
+ let _shared: SharedClient | undefined;
73
+
74
+ export async function tryListStudiosAsync(
75
+ options: StudioMcpOptions = {}
76
+ ): Promise<StudioMcpResult<StudioMcpTarget[]>> {
77
+ return _withMcpAsync(options, async (shared, remainingMs) => {
78
+ const { studios, settled } = await _listSettledAsync(
79
+ shared,
80
+ remainingMs,
81
+ options.sessions
82
+ );
83
+ return { value: await _matchAsync(shared, options, studios), settled };
84
+ });
85
+ }
86
+
87
+ export async function tryExecuteLuauAsync(
88
+ code: string,
89
+ options: StudioMcpLuauOptions = {}
90
+ ): Promise<StudioMcpResult<string>> {
91
+ return tryCallStudioToolAsync('execute_luau', { code }, options);
92
+ }
93
+
94
+ export async function tryCallStudioToolAsync(
95
+ tool: string,
96
+ args: Record<string, unknown> = {},
97
+ options: StudioMcpLuauOptions = {}
98
+ ): Promise<StudioMcpResult<string>> {
99
+ return _withMcpAsync(options, async (shared, remainingMs) => {
100
+ const { studios } = await _listSettledAsync(
101
+ shared,
102
+ remainingMs,
103
+ options.sessions
104
+ );
105
+ const studio = resolveStudio(
106
+ await _matchAsync(shared, options, studios),
107
+ options.target
108
+ );
109
+ const reply = await shared.client.callToolAsync(
110
+ tool,
111
+ {
112
+ ...args,
113
+ studio_id: studio.studioId,
114
+ ...(options.context
115
+ ? { datamodel_type: DATAMODEL_TYPES[options.context] }
116
+ : tool === 'execute_luau'
117
+ ? { datamodel_type: DATAMODEL_TYPES.edit }
118
+ : {}),
119
+ },
120
+ remainingMs()
121
+ );
122
+ if (reply.isError) {
123
+ throw new StudioMcpError(
124
+ 'failed',
125
+ cleanToolError(reply.text) || `${tool} failed`,
126
+ studio
127
+ );
128
+ }
129
+ return { value: reply.text, studio };
130
+ });
131
+ }
132
+
133
+ export async function closeStudioMcpAsync(): Promise<void> {
134
+ const shared = _shared;
135
+ _shared = undefined;
136
+ if (shared) {
137
+ clearTimeout(shared.idleTimer);
138
+ await shared.client.closeAsync();
139
+ }
140
+ }
141
+
142
+ class StudioMcpError extends Error {
143
+ constructor(
144
+ public readonly reason: StudioMcpFailure,
145
+ message: string,
146
+ public readonly studio?: StudioMcpTarget
147
+ ) {
148
+ super(message);
149
+ }
150
+ }
151
+
152
+ async function _withMcpAsync<T>(
153
+ options: StudioMcpOptions,
154
+ runAsync: (
155
+ shared: SharedClient,
156
+ remainingMs: () => number
157
+ ) => Promise<{ value: T; studio?: StudioMcpTarget; settled?: boolean }>
158
+ ): Promise<StudioMcpResult<T>> {
159
+ const totalMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
160
+ const endsAt = Date.now() + totalMs;
161
+ const remainingMs = () => Math.max(1, endsAt - Date.now());
162
+ let shared: SharedClient | undefined;
163
+ let timer: NodeJS.Timeout | undefined;
164
+
165
+ const work = (async (): Promise<StudioMcpResult<T>> => {
166
+ shared = await _acquireAsync(options, remainingMs);
167
+ return { success: true, ...(await runAsync(shared, remainingMs)) };
168
+ })();
169
+ work.catch(() => {});
170
+
171
+ const deadline = new Promise<never>((_, reject) => {
172
+ timer = setTimeout(
173
+ () =>
174
+ reject(
175
+ new McpTimeoutError(
176
+ `Studio's MCP server did not finish within ${totalMs}ms`
177
+ )
178
+ ),
179
+ totalMs
180
+ );
181
+ });
182
+
183
+ try {
184
+ const result = await Promise.race([work, deadline]);
185
+ _scheduleIdleClose(shared);
186
+ return result;
187
+ } catch (err) {
188
+ const failure = _toFailure(err);
189
+ if (failure.reason === 'timeout' || failure.reason === 'unavailable') {
190
+ await _discardAsync(shared);
191
+ } else {
192
+ _scheduleIdleClose(shared);
193
+ }
194
+ return failure;
195
+ } finally {
196
+ clearTimeout(timer);
197
+ }
198
+ }
199
+
200
+ async function _acquireAsync(
201
+ options: StudioMcpOptions,
202
+ remainingMs: () => number
203
+ ): Promise<SharedClient> {
204
+ const key = options.executablePath ?? 'auto';
205
+ if (
206
+ _shared &&
207
+ _shared.client.isRunning &&
208
+ _shared.key === key &&
209
+ _shared.spawnProcess === options.spawnProcess
210
+ ) {
211
+ clearTimeout(_shared.idleTimer);
212
+ return _shared;
213
+ }
214
+ await closeStudioMcpAsync();
215
+
216
+ try {
217
+ const launch = options.executablePath
218
+ ? { command: options.executablePath, args: [] }
219
+ : await findStudioMcpLaunchAsync();
220
+ const client = await McpStdioClient.startAsync(launch.command, {
221
+ args: launch.args,
222
+ env: 'env' in launch ? launch.env : undefined,
223
+ spawnProcess: options.spawnProcess,
224
+ requestTimeoutMs: STEP_TIMEOUT_MS,
225
+ });
226
+ _shared = {
227
+ client,
228
+ startedAt: Date.now(),
229
+ key,
230
+ spawnProcess: options.spawnProcess,
231
+ };
232
+ return _shared;
233
+ } catch (err) {
234
+ if (err instanceof StudioMcpUnsupportedError) {
235
+ throw new StudioMcpError('unsupported', err.message);
236
+ }
237
+ throw new StudioMcpError(
238
+ 'unavailable',
239
+ `Studio's MCP server is unavailable: ${_message(err)}`
240
+ );
241
+ }
242
+ }
243
+
244
+ async function _listSettledAsync(
245
+ shared: SharedClient,
246
+ remainingMs: () => number,
247
+ sessions: SessionInfo[] = []
248
+ ): Promise<{ studios: McpStudio[]; settled: boolean }> {
249
+ const editSessions = sessions.filter((s) => s.context === 'edit').length;
250
+ const isEnough = (found: McpStudio[]) =>
251
+ found.length > 0 && found.length >= editSessions;
252
+ const settlesAt = shared.startedAt + STUDIO_RECONNECT_MS;
253
+ let studios = await listMcpStudiosAsync(shared.client);
254
+ while (
255
+ !isEnough(studios) &&
256
+ Date.now() < settlesAt &&
257
+ remainingMs() > SETTLE_RESERVE_MS
258
+ ) {
259
+ await new Promise((resolve) => setTimeout(resolve, RECONNECT_POLL_MS));
260
+ studios = await listMcpStudiosAsync(shared.client);
261
+ }
262
+ return { studios, settled: isEnough(studios) || Date.now() >= settlesAt };
263
+ }
264
+
265
+ async function _matchAsync(
266
+ shared: SharedClient,
267
+ options: StudioMcpOptions,
268
+ studios: McpStudio[]
269
+ ): Promise<MatchedStudio[]> {
270
+ const windows = await (options.listWindowsAsync ?? _listWindowsAsync)().catch(
271
+ () => []
272
+ );
273
+ return matchStudiosAsync(shared.client, options.sessions ?? [], windows, {
274
+ readPlaceIds: options.readPlaceIds,
275
+ studios,
276
+ sessionPids: options.sessionPids,
277
+ });
278
+ }
279
+
280
+ function _scheduleIdleClose(shared: SharedClient | undefined) {
281
+ if (!shared || shared !== _shared) {
282
+ return;
283
+ }
284
+ clearTimeout(shared.idleTimer);
285
+ shared.idleTimer = setTimeout(() => {
286
+ if (_shared === shared) {
287
+ void closeStudioMcpAsync();
288
+ }
289
+ }, IDLE_CLOSE_MS);
290
+ shared.idleTimer.unref();
291
+ }
292
+
293
+ async function _discardAsync(shared: SharedClient | undefined) {
294
+ if (shared && shared === _shared) {
295
+ await closeStudioMcpAsync();
296
+ } else {
297
+ await shared?.client.closeAsync();
298
+ }
299
+ }
300
+
301
+ function _toFailure(
302
+ err: unknown
303
+ ): Extract<StudioMcpResult<never>, { success: false }> {
304
+ if (err instanceof StudioMcpError) {
305
+ return {
306
+ success: false,
307
+ reason: err.reason,
308
+ message: err.message,
309
+ ...(err.studio ? { studio: err.studio } : {}),
310
+ };
311
+ }
312
+ const message = _message(err);
313
+ if (err instanceof McpTimeoutError) {
314
+ return { success: false, reason: 'timeout', message };
315
+ }
316
+ if (err instanceof StudioNotFoundError) {
317
+ return { success: false, reason: 'no-studio', message };
318
+ }
319
+ return { success: false, reason: 'unavailable', message };
320
+ }
321
+
322
+ async function _listWindowsAsync(): Promise<StudioWindow[]> {
323
+ return (await listStudioProcessesAsync()).map(
324
+ ({ pid, windowTitle, startedAt }) => ({ pid, windowTitle, startedAt })
325
+ );
326
+ }
327
+
328
+ function _message(err: unknown): string {
329
+ return err instanceof Error ? err.message : String(err);
330
+ }
@@ -0,0 +1,284 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { SessionInfo } from '../../bridge/types.js';
3
+ import { McpStdioClient } from '../mcp/mcp-stdio-client.js';
4
+ import { createFakeMcpServer, toolText } from '../test/fake-mcp-server.js';
5
+ import {
6
+ cleanToolError,
7
+ matchStudiosAsync,
8
+ resolveStudio,
9
+ runLuauAsync,
10
+ type MatchedStudio,
11
+ } from './studio-session.js';
12
+
13
+ interface FakeStudio {
14
+ id: string;
15
+ name: string;
16
+ placeId: number;
17
+ gameId: number;
18
+ uptime?: number;
19
+ }
20
+
21
+ async function startAsync(studios: FakeStudio[]) {
22
+ const server = createFakeMcpServer((method, params) => {
23
+ if (method === 'initialize') {
24
+ return { result: { protocolVersion: '2025-06-18', capabilities: {} } };
25
+ }
26
+ if (params?.name === 'list_roblox_studios') {
27
+ return toolText(
28
+ JSON.stringify({
29
+ studios: studios.map(({ id, name }) => ({ id, name })),
30
+ })
31
+ );
32
+ }
33
+ if (params?.name === 'execute_luau') {
34
+ const studio = studios.find((s) => s.id === params.arguments.studio_id);
35
+ if (params.arguments.datamodel_type !== 'Edit') {
36
+ return toolText(
37
+ `${params.arguments.datamodel_type} datamodel is not available in Edit mode`,
38
+ true
39
+ );
40
+ }
41
+ return studio
42
+ ? toolText(`${studio.uptime ?? 0},${studio.placeId},${studio.gameId}`)
43
+ : toolText('not connected', true);
44
+ }
45
+ return undefined;
46
+ });
47
+ const client = await McpStdioClient.startAsync('StudioMCP.exe', {
48
+ spawnProcess: () => server.child,
49
+ });
50
+ return { server, client };
51
+ }
52
+
53
+ function session(overrides: Partial<SessionInfo>): SessionInfo {
54
+ return {
55
+ sessionId: 'place1-aaaa-edit',
56
+ instanceId: 'place1-aaaa',
57
+ placeName: 'Place1',
58
+ state: 'Edit',
59
+ pluginVersion: '1',
60
+ capabilities: [],
61
+ connectedAt: new Date(0),
62
+ origin: 'user',
63
+ context: 'edit',
64
+ placeId: 0,
65
+ gameId: 0,
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ const luauCalls = (server: { requests: Array<{ params: any }> }) =>
71
+ server.requests.filter((r) => r.params?.name === 'execute_luau').length;
72
+
73
+ describe('matchStudiosAsync', () => {
74
+ it('matches a uniquely named Studio to its session without running anything', async () => {
75
+ const { server, client } = await startAsync([
76
+ { id: 'mcp-1', name: 'Place1', placeId: 0, gameId: 0 },
77
+ ]);
78
+
79
+ const [studio] = await matchStudiosAsync(
80
+ client,
81
+ [session({})],
82
+ [{ pid: 7, windowTitle: 'Place1 - Roblox Studio' }]
83
+ );
84
+
85
+ expect(studio).toMatchObject({
86
+ studioId: 'mcp-1',
87
+ sessionId: 'place1-aaaa-edit',
88
+ instanceId: 'place1-aaaa',
89
+ pid: 7,
90
+ });
91
+ expect(luauCalls(server)).toBe(0);
92
+ await client.closeAsync();
93
+ });
94
+
95
+ it('tells same-named Studios apart by place id', async () => {
96
+ const { client } = await startAsync([
97
+ { id: 'mcp-1', name: 'Game', placeId: 111, gameId: 1 },
98
+ { id: 'mcp-2', name: 'Game', placeId: 222, gameId: 2 },
99
+ ]);
100
+
101
+ const studios = await matchStudiosAsync(client, [
102
+ session({
103
+ sessionId: 'a-edit',
104
+ instanceId: 'a',
105
+ placeName: 'Game',
106
+ placeId: 222,
107
+ }),
108
+ session({
109
+ sessionId: 'b-edit',
110
+ instanceId: 'b',
111
+ placeName: 'Game',
112
+ placeId: 111,
113
+ }),
114
+ ]);
115
+
116
+ expect(studios.map((s) => [s.studioId, s.sessionId])).toEqual([
117
+ ['mcp-1', 'b-edit'],
118
+ ['mcp-2', 'a-edit'],
119
+ ]);
120
+ await client.closeAsync();
121
+ });
122
+
123
+ it('leaves two unsaved places with the same name unmatched rather than guess', async () => {
124
+ const { client } = await startAsync([
125
+ { id: 'mcp-1', name: 'Place1', placeId: 0, gameId: 0 },
126
+ { id: 'mcp-2', name: 'Place1', placeId: 0, gameId: 0 },
127
+ ]);
128
+
129
+ const studios = await matchStudiosAsync(client, [
130
+ session({ sessionId: 'a-edit' }),
131
+ session({ sessionId: 'b-edit' }),
132
+ ]);
133
+
134
+ expect(studios.every((s) => s.sessionId === undefined)).toBe(true);
135
+ await client.closeAsync();
136
+ });
137
+
138
+ it('reads place ids for a Studio the bridge has no session for', async () => {
139
+ const { client } = await startAsync([
140
+ { id: 'mcp-1', name: 'Hub', placeId: 333, gameId: 3 },
141
+ ]);
142
+
143
+ const [studio] = await matchStudiosAsync(client, []);
144
+
145
+ expect(studio).toMatchObject({
146
+ studioId: 'mcp-1',
147
+ placeId: 333,
148
+ gameId: 3,
149
+ });
150
+ expect(studio.sessionId).toBeUndefined();
151
+ await client.closeAsync();
152
+ });
153
+ });
154
+
155
+ describe('cleanToolError', () => {
156
+ it("drops the Assistant's own frames from an execute_luau error", () => {
157
+ expect(
158
+ cleanToolError(
159
+ 'sabuiltin_Assistant.rbxm.Assistant.Packages._Index.AssistantUI.AssistantUI.Tools.ExecuteLuauTool:125: sabuiltin_Assistant.rbxm.Assistant.Packages._Index.AssistantUI.AssistantUI.Util.CommandExecution:54: AssistantCommand:1: boom'
160
+ )
161
+ ).toBe('boom');
162
+ });
163
+
164
+ it('leaves other errors alone', () => {
165
+ expect(
166
+ cleanToolError('Server datamodel is not available in Edit mode')
167
+ ).toBe('Server datamodel is not available in Edit mode');
168
+ });
169
+ });
170
+
171
+ describe('resolveStudio', () => {
172
+ const studios: MatchedStudio[] = [
173
+ {
174
+ studioId: '1268b6c6-aaaa',
175
+ placeName: 'Place1',
176
+ sessionId: 'place1-3d83-edit',
177
+ instanceId: 'place1-3d83',
178
+ pid: 10,
179
+ },
180
+ { studioId: '99f00000-bbbb', placeName: 'Hub' },
181
+ ];
182
+
183
+ it('picks the only Studio without a target', () => {
184
+ expect(resolveStudio([studios[1]]).studioId).toBe('99f00000-bbbb');
185
+ });
186
+
187
+ it('asks for --target when several are open', () => {
188
+ expect(() => resolveStudio(studios)).toThrow('Pick one with --target');
189
+ });
190
+
191
+ it.each([
192
+ ['a bridge session id', 'place1-3d83-edit'],
193
+ ['a bridge instance id', 'place1-3d83'],
194
+ ['an instance id prefix', 'place1'],
195
+ ['an MCP studio id prefix', '1268b6c6'],
196
+ ['a pid', '10'],
197
+ ])('resolves %s', (_label, target) => {
198
+ expect(resolveStudio(studios, target).studioId).toBe('1268b6c6-aaaa');
199
+ });
200
+
201
+ it('resolves a place name for a Studio the bridge has no session for', () => {
202
+ expect(resolveStudio(studios, 'hub').studioId).toBe('99f00000-bbbb');
203
+ });
204
+
205
+ it('names the open Studios when nothing matches', () => {
206
+ expect(() => resolveStudio(studios, 'nope')).toThrow(
207
+ "No Studio matches 'nope'"
208
+ );
209
+ });
210
+ });
211
+
212
+ describe('runLuauAsync', () => {
213
+ it('maps a bridge context to the MCP data model and reports its error', async () => {
214
+ const { server, client } = await startAsync([
215
+ { id: 'mcp-1', name: 'Place1', placeId: 0, gameId: 0 },
216
+ ]);
217
+
218
+ const result = await runLuauAsync(client, 'mcp-1', 'return 1', 'server');
219
+
220
+ expect(server.requests.at(-1)?.params.arguments.datamodel_type).toBe(
221
+ 'Server'
222
+ );
223
+ expect(result).toEqual({
224
+ success: false,
225
+ error: 'Server datamodel is not available in Edit mode',
226
+ });
227
+ await client.closeAsync();
228
+ });
229
+ });
230
+
231
+ describe('matchStudiosAsync by uptime', () => {
232
+ it('tells same-named unsaved Studios apart by how long each has run', async () => {
233
+ const now = Date.now();
234
+ const { client } = await startAsync([
235
+ { id: 'mcp-old', name: 'Place1', placeId: 0, gameId: 0, uptime: 300 },
236
+ { id: 'mcp-new', name: 'Place1', placeId: 0, gameId: 0, uptime: 290 },
237
+ ]);
238
+ const windows = [
239
+ {
240
+ pid: 70,
241
+ windowTitle: 'Place1 - Roblox Studio',
242
+ startedAt: now - 300_000,
243
+ },
244
+ {
245
+ pid: 80,
246
+ windowTitle: 'Place1 - Roblox Studio',
247
+ startedAt: now - 290_000,
248
+ },
249
+ ];
250
+
251
+ const studios = await matchStudiosAsync(
252
+ client,
253
+ [
254
+ session({ sessionId: 'a-edit', instanceId: 'a' }),
255
+ session({ sessionId: 'b-edit', instanceId: 'b' }),
256
+ ],
257
+ windows,
258
+ {
259
+ sessionPids: new Map([
260
+ ['a', 80],
261
+ ['b', 70],
262
+ ]),
263
+ }
264
+ );
265
+
266
+ expect(studios.map((s) => [s.studioId, s.pid, s.sessionId])).toEqual([
267
+ ['mcp-old', 70, 'b-edit'],
268
+ ['mcp-new', 80, 'a-edit'],
269
+ ]);
270
+ await client.closeAsync();
271
+ });
272
+
273
+ it('never gives one session to two Studios', async () => {
274
+ const { client } = await startAsync([
275
+ { id: 'mcp-1', name: 'Place1', placeId: 0, gameId: 0 },
276
+ { id: 'mcp-2', name: 'Place1', placeId: 0, gameId: 0 },
277
+ ]);
278
+
279
+ const studios = await matchStudiosAsync(client, [session({})]);
280
+
281
+ expect(studios.filter((s) => s.sessionId).length).toBe(0);
282
+ await client.closeAsync();
283
+ });
284
+ });