@quenty/studio-bridge 0.22.1 → 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.
- package/CHANGELOG.md +25 -0
- package/README.md +36 -8
- package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
- package/dist/src/bridge/bridge-connection.js +1 -0
- package/dist/src/bridge/bridge-connection.js.map +1 -1
- package/dist/src/bridge/bridge-session.d.ts +11 -16
- package/dist/src/bridge/bridge-session.d.ts.map +1 -1
- package/dist/src/bridge/bridge-session.js +37 -29
- package/dist/src/bridge/bridge-session.js.map +1 -1
- package/dist/src/bridge/bridge-session.test.js +35 -0
- package/dist/src/bridge/bridge-session.test.js.map +1 -1
- package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
- package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
- package/dist/src/bridge/internal/bridge-host.js +1 -0
- package/dist/src/bridge/internal/bridge-host.js.map +1 -1
- package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
- package/dist/src/bridge/internal/host-protocol.js +23 -10
- package/dist/src/bridge/internal/host-protocol.js.map +1 -1
- package/dist/src/bridge/types.d.ts +5 -2
- package/dist/src/bridge/types.d.ts.map +1 -1
- package/dist/src/bridge/types.js.map +1 -1
- package/dist/src/commands/console/exec/exec.d.ts +13 -3
- package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
- package/dist/src/commands/console/exec/exec.js +82 -7
- package/dist/src/commands/console/exec/exec.js.map +1 -1
- package/dist/src/commands/console/exec/exec.test.js +63 -1
- package/dist/src/commands/console/exec/exec.test.js.map +1 -1
- package/dist/src/commands/framework/action-loader.test.js +1 -1
- package/dist/src/commands/framework/action-loader.test.js.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.js +15 -93
- package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
- package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
- package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
- package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.js +54 -74
- package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
- package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
- package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
- package/dist/src/commands/playtest/run/run.d.ts +9 -7
- package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
- package/dist/src/commands/playtest/run/run.js +33 -38
- package/dist/src/commands/playtest/run/run.js.map +1 -1
- package/dist/src/commands/process/close/close.d.ts +4 -0
- package/dist/src/commands/process/close/close.d.ts.map +1 -1
- package/dist/src/commands/process/close/close.js +54 -4
- package/dist/src/commands/process/close/close.js.map +1 -1
- package/dist/src/commands/process/close/close.test.js +36 -3
- package/dist/src/commands/process/close/close.test.js.map +1 -1
- package/dist/src/commands/process/info/info.d.ts +15 -2
- package/dist/src/commands/process/info/info.d.ts.map +1 -1
- package/dist/src/commands/process/info/info.js +44 -4
- package/dist/src/commands/process/info/info.js.map +1 -1
- package/dist/src/commands/process/info/info.test.js +48 -0
- package/dist/src/commands/process/info/info.test.js.map +1 -1
- package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
- package/dist/src/commands/process/launch/launch.js +22 -3
- package/dist/src/commands/process/launch/launch.js.map +1 -1
- package/dist/src/commands/process/launch/launch.test.js +27 -5
- package/dist/src/commands/process/launch/launch.test.js.map +1 -1
- package/dist/src/commands/process/list/list.d.ts +49 -6
- package/dist/src/commands/process/list/list.d.ts.map +1 -1
- package/dist/src/commands/process/list/list.js +185 -18
- package/dist/src/commands/process/list/list.js.map +1 -1
- package/dist/src/commands/process/list/list.test.js +92 -1
- package/dist/src/commands/process/list/list.test.js.map +1 -1
- package/dist/src/commands/process/run/run.d.ts +1 -1
- package/dist/src/commands/process/run/run.d.ts.map +1 -1
- package/dist/src/commands/process/run/run.js +3 -3
- package/dist/src/commands/process/run/run.js.map +1 -1
- package/dist/src/docker/docker-delegator.js +1 -1
- package/dist/src/docker/docker-delegator.js.map +1 -1
- package/dist/src/docker/docker-delegator.test.js +1 -1
- package/dist/src/docker/docker-delegator.test.js.map +1 -1
- package/dist/src/plugin/playtest-bridge.d.ts +36 -0
- package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
- package/dist/src/plugin/playtest-bridge.js +65 -0
- package/dist/src/plugin/playtest-bridge.js.map +1 -0
- package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
- package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
- package/dist/src/plugin/playtest-bridge.test.js +35 -0
- package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
- package/dist/src/process/studio-launch-registry.d.ts +14 -0
- package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
- package/dist/src/process/studio-launch-registry.js +37 -0
- package/dist/src/process/studio-launch-registry.js.map +1 -0
- package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
- package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
- package/dist/src/process/studio-launch-registry.test.js +38 -0
- package/dist/src/process/studio-launch-registry.test.js.map +1 -0
- package/dist/src/process/studio-process-closer.d.ts +3 -1
- package/dist/src/process/studio-process-closer.d.ts.map +1 -1
- package/dist/src/process/studio-process-closer.js +29 -11
- package/dist/src/process/studio-process-closer.js.map +1 -1
- package/dist/src/process/studio-process-identity.d.ts +10 -0
- package/dist/src/process/studio-process-identity.d.ts.map +1 -0
- package/dist/src/process/studio-process-identity.js +63 -0
- package/dist/src/process/studio-process-identity.js.map +1 -0
- package/dist/src/process/studio-process-identity.test.d.ts +2 -0
- package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
- package/dist/src/process/studio-process-identity.test.js +30 -0
- package/dist/src/process/studio-process-identity.test.js.map +1 -0
- package/dist/src/roblox-mcp/index.d.ts +3 -0
- package/dist/src/roblox-mcp/index.d.ts.map +1 -0
- package/dist/src/roblox-mcp/index.js +3 -0
- package/dist/src/roblox-mcp/index.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
- package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
- package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
- package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
- package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
- package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
- package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
- package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
- package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
- package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
- package/dist/src/server/web-socket-protocol.d.ts +50 -15
- package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
- package/dist/src/server/web-socket-protocol.js +63 -13
- package/dist/src/server/web-socket-protocol.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/playtest-bridge.md +21 -0
- package/package.json +5 -5
- package/src/bridge/bridge-connection.ts +1 -0
- package/src/bridge/bridge-session.test.ts +51 -0
- package/src/bridge/bridge-session.ts +66 -47
- package/src/bridge/internal/bridge-host.ts +3 -0
- package/src/bridge/internal/host-protocol.ts +22 -9
- package/src/bridge/types.ts +5 -0
- package/src/commands/console/exec/exec.test.ts +94 -0
- package/src/commands/console/exec/exec.ts +130 -16
- package/src/commands/console/exec/execute.lua +147 -5
- package/src/commands/console/exec/execute.spec.lua +22 -2
- package/src/commands/framework/action-loader.test.ts +1 -1
- package/src/commands/playtest/player/playtest-players.lua +127 -0
- package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
- package/src/commands/playtest/player/playtest-players.test.ts +40 -21
- package/src/commands/playtest/player/playtest-players.ts +30 -110
- package/src/commands/playtest/playtest-sessions.test.ts +116 -14
- package/src/commands/playtest/playtest-sessions.ts +77 -88
- package/src/commands/playtest/run/run.ts +44 -58
- package/src/commands/playtest/start/start-playtest.lua +105 -6
- package/src/commands/process/close/close.test.ts +52 -3
- package/src/commands/process/close/close.ts +78 -4
- package/src/commands/process/info/info.test.ts +76 -0
- package/src/commands/process/info/info.ts +74 -6
- package/src/commands/process/launch/launch.test.ts +33 -5
- package/src/commands/process/launch/launch.ts +41 -3
- package/src/commands/process/list/list.test.ts +130 -1
- package/src/commands/process/list/list.ts +290 -22
- package/src/commands/process/run/run.ts +4 -4
- package/src/docker/docker-delegator.test.ts +1 -1
- package/src/docker/docker-delegator.ts +1 -1
- package/src/plugin/playtest-bridge.test.ts +53 -0
- package/src/plugin/playtest-bridge.ts +98 -0
- package/src/process/studio-launch-registry.test.ts +56 -0
- package/src/process/studio-launch-registry.ts +63 -0
- package/src/process/studio-process-closer.ts +36 -11
- package/src/process/studio-process-identity.test.ts +45 -0
- package/src/process/studio-process-identity.ts +90 -0
- package/src/roblox-mcp/index.ts +17 -0
- package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
- package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
- package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
- package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
- package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
- package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
- package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
- package/src/roblox-mcp/studio/studio-session.ts +279 -0
- package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
- package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
- package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
- package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
- package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
- package/src/server/web-socket-protocol.ts +119 -29
- package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
- package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
- package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
- package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
- package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
- package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
- package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
- package/src/commands/playtest/run/playtest-runner.luau +0 -264
- package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
- package/src/commands/playtest/run/run-playtest-script.lua +0 -139
- 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
|
+
});
|