@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,270 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
2
+ import { repairListResult } from './repair-list-result.js';
3
+
4
+ export const MCP_PROTOCOL_VERSION = '2025-06-18';
5
+
6
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
7
+ const STDERR_TAIL_BYTES = 4096;
8
+ const CLOSE_TIMEOUT_MS = 2000;
9
+
10
+ export class McpTimeoutError extends Error {}
11
+
12
+ export interface McpTool {
13
+ name: string;
14
+ description?: string;
15
+ inputSchema?: Record<string, unknown>;
16
+ }
17
+
18
+ export interface McpContent {
19
+ type: string;
20
+ text?: string;
21
+ [key: string]: unknown;
22
+ }
23
+
24
+ export interface McpToolResult {
25
+ isError: boolean;
26
+ content: McpContent[];
27
+ text: string;
28
+ }
29
+
30
+ export type SpawnMcpProcess = (
31
+ command: string,
32
+ args: string[]
33
+ ) => ChildProcessWithoutNullStreams;
34
+
35
+ export interface McpStdioClientOptions {
36
+ args?: string[];
37
+ env?: Record<string, string>;
38
+ spawnProcess?: SpawnMcpProcess;
39
+ requestTimeoutMs?: number;
40
+ clientInfo?: { name: string; version: string };
41
+ }
42
+
43
+ interface PendingRequest {
44
+ method: string;
45
+ resolve: (result: unknown) => void;
46
+ reject: (err: Error) => void;
47
+ timer: NodeJS.Timeout;
48
+ }
49
+
50
+ export class McpStdioClient {
51
+ private _child: ChildProcessWithoutNullStreams;
52
+ private _pending = new Map<number, PendingRequest>();
53
+ private _nextId = 1;
54
+ private _buffer = '';
55
+ private _stderrTail = '';
56
+ private _exitError: Error | undefined;
57
+ private _exited = false;
58
+ private _requestTimeoutMs: number;
59
+ private _protocolVersion: string | undefined;
60
+ private _serverInfo: { name?: string; version?: string } | undefined;
61
+
62
+ private constructor(
63
+ child: ChildProcessWithoutNullStreams,
64
+ requestTimeoutMs: number
65
+ ) {
66
+ this._child = child;
67
+ this._requestTimeoutMs = requestTimeoutMs;
68
+
69
+ child.stdout.setEncoding('utf8');
70
+ child.stdout.on('data', (chunk: string) => this._onStdout(chunk));
71
+ child.stderr.setEncoding('utf8');
72
+ child.stderr.on('data', (chunk: string) => {
73
+ this._stderrTail = (this._stderrTail + chunk).slice(-STDERR_TAIL_BYTES);
74
+ });
75
+ child.stdin.on('error', () => {});
76
+ child.unref?.();
77
+ for (const stream of [child.stdin, child.stdout, child.stderr]) {
78
+ (stream as { unref?: () => void }).unref?.();
79
+ }
80
+ child.on('error', (err) => this._fail(err));
81
+ child.on('close', (code) => {
82
+ this._exited = true;
83
+ this._fail(
84
+ new Error(
85
+ `Studio's MCP server exited (code ${code ?? 'none'})${
86
+ this._stderrTail.trim() ? `: ${this._stderrTail.trim()}` : ''
87
+ }`
88
+ )
89
+ );
90
+ });
91
+ }
92
+
93
+ static async startAsync(
94
+ command: string,
95
+ options: McpStdioClientOptions = {}
96
+ ): Promise<McpStdioClient> {
97
+ const spawnProcess: SpawnMcpProcess =
98
+ options.spawnProcess ??
99
+ ((cmd, args) =>
100
+ spawn(cmd, args, {
101
+ stdio: 'pipe',
102
+ windowsHide: true,
103
+ ...(options.env ? { env: options.env } : {}),
104
+ }));
105
+ const client = new McpStdioClient(
106
+ spawnProcess(command, options.args ?? []),
107
+ options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
108
+ );
109
+
110
+ try {
111
+ const result = (await client.requestAsync('initialize', {
112
+ protocolVersion: MCP_PROTOCOL_VERSION,
113
+ capabilities: {},
114
+ clientInfo: options.clientInfo ?? {
115
+ name: 'studio-bridge',
116
+ version: '0.0.0',
117
+ },
118
+ })) as {
119
+ protocolVersion?: string;
120
+ serverInfo?: { name?: string; version?: string };
121
+ };
122
+ client._protocolVersion = result?.protocolVersion;
123
+ client._serverInfo = result?.serverInfo;
124
+ client._notify('notifications/initialized');
125
+ } catch (err) {
126
+ await client.closeAsync();
127
+ throw err;
128
+ }
129
+ return client;
130
+ }
131
+
132
+ get protocolVersion(): string | undefined {
133
+ return this._protocolVersion;
134
+ }
135
+
136
+ get serverInfo(): { name?: string; version?: string } | undefined {
137
+ return this._serverInfo;
138
+ }
139
+
140
+ async listToolsAsync(): Promise<McpTool[]> {
141
+ const result = (await this.requestAsync('tools/list', {})) as {
142
+ tools?: McpTool[];
143
+ };
144
+ return Array.isArray(result?.tools) ? result.tools : [];
145
+ }
146
+
147
+ async callToolAsync(
148
+ name: string,
149
+ args: Record<string, unknown> = {},
150
+ timeoutMs?: number
151
+ ): Promise<McpToolResult> {
152
+ const result = (await this.requestAsync(
153
+ 'tools/call',
154
+ { name, arguments: args },
155
+ timeoutMs
156
+ )) as { isError?: boolean; content?: McpContent[] };
157
+ const content = Array.isArray(result?.content) ? result.content : [];
158
+ return {
159
+ isError: result?.isError === true,
160
+ content,
161
+ text: content
162
+ .filter((c) => c.type === 'text' && typeof c.text === 'string')
163
+ .map((c) => c.text)
164
+ .join('\n'),
165
+ };
166
+ }
167
+
168
+ requestAsync(
169
+ method: string,
170
+ params: unknown,
171
+ timeoutMs = this._requestTimeoutMs
172
+ ): Promise<unknown> {
173
+ if (this._exitError) {
174
+ return Promise.reject(this._exitError);
175
+ }
176
+ const id = this._nextId++;
177
+ return new Promise((resolve, reject) => {
178
+ const timer = setTimeout(() => {
179
+ this._pending.delete(id);
180
+ reject(new McpTimeoutError(`${method} timed out after ${timeoutMs}ms`));
181
+ }, timeoutMs);
182
+ this._pending.set(id, { method, resolve, reject, timer });
183
+ this._write({ jsonrpc: '2.0', id, method, params });
184
+ });
185
+ }
186
+
187
+ get isRunning(): boolean {
188
+ return !this._exited;
189
+ }
190
+
191
+ async closeAsync(): Promise<void> {
192
+ this._fail(new Error("Studio's MCP client was closed"));
193
+ if (this._exited) {
194
+ return;
195
+ }
196
+ let timer: NodeJS.Timeout | undefined;
197
+ const exited = new Promise<void>((resolve) => {
198
+ this._child.once('close', () => resolve());
199
+ timer = setTimeout(resolve, CLOSE_TIMEOUT_MS);
200
+ });
201
+ this._child.stdin.end();
202
+ this._child.kill();
203
+ await exited;
204
+ clearTimeout(timer);
205
+ }
206
+
207
+ private _notify(method: string, params?: unknown) {
208
+ this._write({ jsonrpc: '2.0', method, ...(params ? { params } : {}) });
209
+ }
210
+
211
+ private _write(message: unknown) {
212
+ this._child.stdin.write(JSON.stringify(message) + '\n');
213
+ }
214
+
215
+ private _onStdout(chunk: string) {
216
+ this._buffer += chunk;
217
+ let newline: number;
218
+ while ((newline = this._buffer.indexOf('\n')) >= 0) {
219
+ const line = this._buffer.slice(0, newline).trim();
220
+ this._buffer = this._buffer.slice(newline + 1);
221
+ if (line) {
222
+ this._onLine(line);
223
+ }
224
+ }
225
+ }
226
+
227
+ private _onLine(line: string) {
228
+ let message: {
229
+ id?: unknown;
230
+ method?: unknown;
231
+ result?: unknown;
232
+ error?: { message?: string; code?: number };
233
+ };
234
+ try {
235
+ message = JSON.parse(line);
236
+ } catch {
237
+ return;
238
+ }
239
+ if (typeof message?.id !== 'number' || message.method !== undefined) {
240
+ return;
241
+ }
242
+ const pending = this._pending.get(message.id);
243
+ if (!pending) {
244
+ return;
245
+ }
246
+ this._pending.delete(message.id);
247
+ clearTimeout(pending.timer);
248
+
249
+ if (message.error) {
250
+ pending.reject(
251
+ new Error(
252
+ `${pending.method} failed: ${
253
+ message.error.message ?? `error ${message.error.code ?? ''}`
254
+ }`.trim()
255
+ )
256
+ );
257
+ } else {
258
+ pending.resolve(repairListResult(message.result));
259
+ }
260
+ }
261
+
262
+ private _fail(err: Error) {
263
+ this._exitError ??= err;
264
+ for (const pending of this._pending.values()) {
265
+ clearTimeout(pending.timer);
266
+ pending.reject(this._exitError);
267
+ }
268
+ this._pending.clear();
269
+ }
270
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { repairListResult } from './repair-list-result.js';
3
+
4
+ describe('repairListResult', () => {
5
+ it('fills in the caching hints a 2026-07-28 list result left out', () => {
6
+ expect(repairListResult({ resultType: 'complete', tools: [] })).toEqual({
7
+ resultType: 'complete',
8
+ tools: [],
9
+ ttlMs: 0,
10
+ cacheScope: 'private',
11
+ });
12
+ });
13
+
14
+ it('keeps hints the server sent', () => {
15
+ const result = {
16
+ resultType: 'complete',
17
+ tools: [],
18
+ ttlMs: 500,
19
+ cacheScope: 'shared',
20
+ };
21
+
22
+ expect(repairListResult(result)).toBe(result);
23
+ });
24
+
25
+ it('fills only the hint that is missing', () => {
26
+ expect(
27
+ repairListResult({ resultType: 'complete', prompts: [], ttlMs: 10 })
28
+ ).toEqual({
29
+ resultType: 'complete',
30
+ prompts: [],
31
+ ttlMs: 10,
32
+ cacheScope: 'private',
33
+ });
34
+ });
35
+
36
+ it('leaves an older protocol result alone', () => {
37
+ const result = { tools: [] };
38
+
39
+ expect(repairListResult(result)).toBe(result);
40
+ });
41
+
42
+ it('leaves a result that is not a list alone', () => {
43
+ const result = { resultType: 'complete', content: [] };
44
+
45
+ expect(repairListResult(result)).toBe(result);
46
+ });
47
+
48
+ it('passes non-objects through', () => {
49
+ expect(repairListResult(undefined)).toBeUndefined();
50
+ expect(repairListResult('text')).toBe('text');
51
+ });
52
+ });
@@ -0,0 +1,23 @@
1
+ const LIST_KEYS = ['tools', 'prompts', 'resources', 'resourceTemplates'];
2
+
3
+ export function repairListResult<T>(result: T): T {
4
+ if (!result || typeof result !== 'object') {
5
+ return result;
6
+ }
7
+ const record = result as Record<string, unknown>;
8
+ if (record.resultType === undefined) {
9
+ return result;
10
+ }
11
+ if (!LIST_KEYS.some((key) => Array.isArray(record[key]))) {
12
+ return result;
13
+ }
14
+ if (record.ttlMs !== undefined && record.cacheScope !== undefined) {
15
+ return result;
16
+ }
17
+
18
+ return {
19
+ ...record,
20
+ ttlMs: record.ttlMs ?? 0,
21
+ cacheScope: record.cacheScope ?? 'private',
22
+ } as T;
23
+ }
@@ -0,0 +1,168 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ closeStudioMcpAsync,
4
+ tryCallStudioToolAsync,
5
+ tryExecuteLuauAsync,
6
+ tryListStudiosAsync,
7
+ } from './roblox-studio-mcp.js';
8
+ import {
9
+ createFakeMcpServer,
10
+ toolText,
11
+ type FakeHandler,
12
+ } from './test/fake-mcp-server.js';
13
+
14
+ const STUDIOS = [
15
+ { id: 'mcp-1', name: 'Place1' },
16
+ { id: 'mcp-2', name: 'Hub' },
17
+ ];
18
+
19
+ function fake(handler: FakeHandler = () => undefined, studios = STUDIOS) {
20
+ const server = createFakeMcpServer((method, params) => {
21
+ if (method === 'initialize') {
22
+ return { result: { protocolVersion: '2025-06-18', capabilities: {} } };
23
+ }
24
+ if (params?.name === 'list_roblox_studios') {
25
+ return toolText(JSON.stringify({ studios }));
26
+ }
27
+ if (
28
+ params?.name === 'execute_luau' &&
29
+ params.arguments.code.includes('os.clock')
30
+ ) {
31
+ return toolText('0,0,0');
32
+ }
33
+ return handler(method, params);
34
+ });
35
+ return {
36
+ server,
37
+ options: {
38
+ executablePath: 'StudioMCP.exe',
39
+ spawnProcess: () => server.child,
40
+ listWindowsAsync: async () => [],
41
+ },
42
+ };
43
+ }
44
+
45
+ afterEach(async () => {
46
+ vi.useRealTimers();
47
+ await closeStudioMcpAsync();
48
+ });
49
+
50
+ describe('tryExecuteLuauAsync', () => {
51
+ it('runs Luau in the targeted Studio and returns its result', async () => {
52
+ const { server, options } = fake((_method, params) =>
53
+ params?.name === 'execute_luau' ? toolText('42') : undefined
54
+ );
55
+
56
+ const result = await tryExecuteLuauAsync('return 42', {
57
+ ...options,
58
+ target: 'hub',
59
+ });
60
+
61
+ expect(result).toMatchObject({ success: true, value: '42' });
62
+ expect(server.requests.at(-1)?.params.arguments).toEqual({
63
+ code: 'return 42',
64
+ studio_id: 'mcp-2',
65
+ datamodel_type: 'Edit',
66
+ });
67
+ });
68
+
69
+ it('sends the context as the data model', async () => {
70
+ const { server, options } = fake(() => toolText('ok'));
71
+
72
+ await tryExecuteLuauAsync('return 1', {
73
+ ...options,
74
+ target: 'hub',
75
+ context: 'server',
76
+ });
77
+
78
+ expect(server.requests.at(-1)?.params.arguments.datamodel_type).toBe(
79
+ 'Server'
80
+ );
81
+ });
82
+
83
+ it('fails with the script error, naming the Studio', async () => {
84
+ const { options } = fake(() => toolText('boom', true));
85
+
86
+ const result = await tryExecuteLuauAsync('error("boom")', {
87
+ ...options,
88
+ target: 'hub',
89
+ });
90
+
91
+ expect(result).toMatchObject({
92
+ success: false,
93
+ reason: 'failed',
94
+ message: 'boom',
95
+ studio: { studioId: 'mcp-2' },
96
+ });
97
+ });
98
+
99
+ it('fails with no-studio when the target is ambiguous', async () => {
100
+ const { options } = fake();
101
+
102
+ const result = await tryExecuteLuauAsync('return 1', options);
103
+
104
+ expect(result).toMatchObject({ success: false, reason: 'no-studio' });
105
+ });
106
+
107
+ it('fails with unavailable when the server exits before answering', async () => {
108
+ const server = createFakeMcpServer(() => undefined);
109
+ server.exit(1, 'no Studio');
110
+
111
+ const result = await tryExecuteLuauAsync('return 1', {
112
+ executablePath: 'StudioMCP.exe',
113
+ spawnProcess: () => server.child,
114
+ listWindowsAsync: async () => [],
115
+ });
116
+
117
+ expect(result).toMatchObject({ success: false, reason: 'unavailable' });
118
+ expect(result.success || result.message).toContain('no Studio');
119
+ });
120
+
121
+ it('fails with timeout when the whole call outlives its deadline', async () => {
122
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
123
+ const { server, options } = fake(() => undefined);
124
+
125
+ const pending = tryExecuteLuauAsync('return 1', {
126
+ ...options,
127
+ target: 'hub',
128
+ });
129
+ await vi.advanceTimersByTimeAsync(15_000);
130
+
131
+ expect(await pending).toMatchObject({ success: false, reason: 'timeout' });
132
+ expect(server.child.killed).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe('tryCallStudioToolAsync', () => {
137
+ it('fills in the studio id and leaves other tools without a data model', async () => {
138
+ const { server, options } = fake(() => toolText('Edit'));
139
+
140
+ const result = await tryCallStudioToolAsync(
141
+ 'get_studio_state',
142
+ {},
143
+ {
144
+ ...options,
145
+ target: 'place1',
146
+ }
147
+ );
148
+
149
+ expect(result).toMatchObject({ success: true, value: 'Edit' });
150
+ expect(server.requests.at(-1)?.params).toEqual({
151
+ name: 'get_studio_state',
152
+ arguments: { studio_id: 'mcp-1' },
153
+ });
154
+ });
155
+ });
156
+
157
+ describe('tryListStudiosAsync', () => {
158
+ it('lists the open Studios', async () => {
159
+ const { options } = fake();
160
+
161
+ const result = await tryListStudiosAsync(options);
162
+
163
+ expect(result.success && result.value.map((s) => s.placeName)).toEqual([
164
+ 'Place1',
165
+ 'Hub',
166
+ ]);
167
+ });
168
+ });