@scenoco-three/editor 0.2.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/README.md +101 -0
- package/app/cameraRig.ts +57 -0
- package/app/commands.ts +74 -0
- package/app/desktopControls.ts +44 -0
- package/app/document.ts +500 -0
- package/app/gizmo.ts +75 -0
- package/app/index.html +103 -0
- package/app/main.ts +302 -0
- package/app/panels.ts +553 -0
- package/app/session.ts +294 -0
- package/app/toolbar.ts +131 -0
- package/app/touchControls.ts +104 -0
- package/app/vite-env.d.ts +5 -0
- package/app/wsClient.ts +66 -0
- package/dist/args.d.ts +8 -0
- package/dist/args.js +28 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +74 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.js +150 -0
- package/dist/protocol.d.ts +157 -0
- package/dist/protocol.js +9 -0
- package/dist/server.d.ts +44 -0
- package/dist/server.js +264 -0
- package/package.json +53 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { parseArgs, parseList } from '../args.js';
|
|
5
|
+
import { startEditorServer } from '../server.js';
|
|
6
|
+
import { startMcpServer } from '../mcp.js';
|
|
7
|
+
const USAGE = `scenoco-editor — open a scene/prefab and drive it from an MCP client
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
scenoco-editor <scene|prefab> [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--components <dirs> Comma-separated component dirs (default: "src")
|
|
14
|
+
--packages <pkgs> Comma-separated tag packages (e.g. @scenoco-three/box2d)
|
|
15
|
+
--port <n> Fixed editor server port (default: a free port)
|
|
16
|
+
--no-open Don't auto-open the editor in a browser
|
|
17
|
+
|
|
18
|
+
Starts a local editor (opened in your browser) and an MCP server. Point an MCP
|
|
19
|
+
client at the printed endpoint; its tools — open_file, get_preview, run, pause,
|
|
20
|
+
stop, get_telemetry — drive the live editor.`;
|
|
21
|
+
async function main(argv) {
|
|
22
|
+
const { positionals, options } = parseArgs(argv);
|
|
23
|
+
const file = positionals[0];
|
|
24
|
+
if (file === undefined || file === '-h' || file === '--help') {
|
|
25
|
+
console.log(USAGE);
|
|
26
|
+
return file === undefined ? 1 : 0;
|
|
27
|
+
}
|
|
28
|
+
// npm/npx rewrites process.cwd() to the nearest enclosing package.json when
|
|
29
|
+
// run from inside a workspace package without its own node_modules/.bin
|
|
30
|
+
// (e.g. `npx scenoco-editor` from examples/breakout/scenes). INIT_CWD still
|
|
31
|
+
// holds the shell's actual directory.
|
|
32
|
+
const cwd = process.env.INIT_CWD || process.cwd();
|
|
33
|
+
const editor = await startEditorServer({
|
|
34
|
+
cwd,
|
|
35
|
+
componentRoots: parseList(options.components) ?? ['src'],
|
|
36
|
+
packages: parseList(options.packages) ?? [],
|
|
37
|
+
...(options.port ? { port: Number(options.port) } : {}),
|
|
38
|
+
});
|
|
39
|
+
const mcp = await startMcpServer(editor.bridge);
|
|
40
|
+
const appUrl = `${editor.url}?file=${encodeURIComponent(resolve(cwd, file))}`;
|
|
41
|
+
console.log(`\n editor: ${appUrl}`);
|
|
42
|
+
if (editor.networkUrl)
|
|
43
|
+
console.log(` wi-fi: ${editor.networkUrl}?file=${encodeURIComponent(resolve(cwd, file))}`);
|
|
44
|
+
console.log(` mcp: ${mcp.url} ← add this to your MCP client (transport: http)`);
|
|
45
|
+
console.log(`\n press Ctrl+C to stop\n`);
|
|
46
|
+
if (!('no-open' in options))
|
|
47
|
+
openBrowser(appUrl);
|
|
48
|
+
const shutdown = async () => {
|
|
49
|
+
// Connected browsers keep WS/HMR/keep-alive sockets open, so `close()` can
|
|
50
|
+
// stall waiting for them to drain — cap it so Ctrl+C always exits promptly.
|
|
51
|
+
const deadline = new Promise((r) => setTimeout(r, 1500).unref());
|
|
52
|
+
await Promise.race([Promise.all([mcp.close().catch(() => { }), editor.close().catch(() => { })]), deadline]);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
};
|
|
55
|
+
process.on('SIGINT', () => void shutdown());
|
|
56
|
+
process.on('SIGTERM', () => void shutdown());
|
|
57
|
+
await new Promise(() => { }); // keep alive
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
/** Open a URL in the default browser (best-effort, per platform). */
|
|
61
|
+
function openBrowser(url) {
|
|
62
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
63
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
64
|
+
try {
|
|
65
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* non-fatal: the URL is printed */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
process.exitCode = await main(process.argv.slice(2)).catch((e) => {
|
|
72
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
73
|
+
return 1;
|
|
74
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { startEditorServer, type ServerOptions, type EditorServer, type EditorBridge } from './server.js';
|
|
2
|
+
export { startMcpServer, buildMcpServer, type McpHandle } from './mcp.js';
|
|
3
|
+
export { WS_PATH, commandId, type EditorCommand, type EditorResponse, type EditorResult, } from './protocol.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `@scenoco-three/editor` — programmatic API behind the `scenoco-editor` CLI.
|
|
2
|
+
// Serves the editor app, bridges it to an MCP server over a WebSocket, and exposes
|
|
3
|
+
// MCP tools (open_file / get_preview / run / pause / stop / get_telemetry).
|
|
4
|
+
export { startEditorServer } from './server.js';
|
|
5
|
+
export { startMcpServer, buildMcpServer } from './mcp.js';
|
|
6
|
+
export { WS_PATH, commandId, } from './protocol.js';
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { EditorBridge } from './server.js';
|
|
3
|
+
export interface McpHandle {
|
|
4
|
+
/** The MCP endpoint URL to add to an MCP client. */
|
|
5
|
+
url: string;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Start a stateless MCP server (Streamable HTTP) whose tools relay to the live
|
|
10
|
+
* editor over `bridge`. A fresh `McpServer` + transport is built per request.
|
|
11
|
+
*/
|
|
12
|
+
export declare function startMcpServer(bridge: EditorBridge, opts?: {
|
|
13
|
+
port?: number;
|
|
14
|
+
}): Promise<McpHandle>;
|
|
15
|
+
/** Build an McpServer whose tools relay to the editor. */
|
|
16
|
+
export declare function buildMcpServer(bridge: EditorBridge): McpServer;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
2
|
+
import { createServer as createNetServer } from 'node:net';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { commandId } from './protocol.js';
|
|
7
|
+
/**
|
|
8
|
+
* Start a stateless MCP server (Streamable HTTP) whose tools relay to the live
|
|
9
|
+
* editor over `bridge`. A fresh `McpServer` + transport is built per request.
|
|
10
|
+
*/
|
|
11
|
+
export async function startMcpServer(bridge, opts = {}) {
|
|
12
|
+
const port = opts.port ?? (await freePort());
|
|
13
|
+
const http = createHttpServer((req, res) => void handle(req, res, bridge));
|
|
14
|
+
await new Promise((resolve) => http.listen(port, resolve));
|
|
15
|
+
return {
|
|
16
|
+
url: `http://localhost:${port}/mcp`,
|
|
17
|
+
close: () => new Promise((resolve) => http.close(() => resolve())),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Build an McpServer whose tools relay to the editor. */
|
|
21
|
+
export function buildMcpServer(bridge) {
|
|
22
|
+
const server = new McpServer({ name: 'scenoco-editor', version: '0.1.0' });
|
|
23
|
+
const relay = (cmd) => relayToEditor(bridge, cmd);
|
|
24
|
+
server.registerTool('open_file', { description: 'Open a scene (.scene.xml) or prefab (.prefab.xml) in the editor (edit mode).', inputSchema: { path: z.string() } }, ({ path }) => relay({ id: commandId(), type: 'open', path }));
|
|
25
|
+
server.registerTool('get_preview', {
|
|
26
|
+
description: 'Capture the current editor view as a PNG. Optionally frame a specific camera.',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
camera: z.string().optional().describe('Camera position "x y z"'),
|
|
29
|
+
lookAt: z.string().optional().describe('Look-at point "x y z"'),
|
|
30
|
+
},
|
|
31
|
+
}, ({ camera, lookAt }) => relay({ id: commandId(), type: 'preview', ...(camera ? { camera } : {}), ...(lookAt ? { lookAt } : {}) }));
|
|
32
|
+
server.registerTool('playback', {
|
|
33
|
+
description: 'Control gameplay: play (run components/systems/physics), pause, or stop (back to edit mode).',
|
|
34
|
+
inputSchema: { state: z.enum(['play', 'pause', 'stop']) },
|
|
35
|
+
}, ({ state }) => relay({ id: commandId(), type: state === 'play' ? 'run' : state }));
|
|
36
|
+
server.registerTool('get_telemetry', { description: 'Current property values (JSON) for the node/components at a scene id.', inputSchema: { id: z.string() } }, ({ id }) => relay({ id: commandId(), type: 'telemetry', targetId: id }));
|
|
37
|
+
// A simulation command — one entry in the `simulate` batch. Loose (all fields
|
|
38
|
+
// optional) so the agent composes any step; only `do` is required.
|
|
39
|
+
const simStep = z.object({
|
|
40
|
+
do: z.enum(['play', 'pause', 'stop', 'step', 'tap', 'keyDown', 'keyUp', 'click', 'move', 'gamepad', 'preview', 'telemetry']),
|
|
41
|
+
key: z.string().optional().describe("tap/keyDown/keyUp: key name, e.g. 'Space', 'W', 'LeftArrow'"),
|
|
42
|
+
frames: z.number().int().optional(),
|
|
43
|
+
dt: z.number().optional(),
|
|
44
|
+
x: z.number().optional(),
|
|
45
|
+
y: z.number().optional(),
|
|
46
|
+
dx: z.number().optional(),
|
|
47
|
+
dy: z.number().optional(),
|
|
48
|
+
button: z.enum(['left', 'right', 'middle']).optional(),
|
|
49
|
+
index: z.number().int().optional(),
|
|
50
|
+
buttons: z.array(z.boolean()).optional(),
|
|
51
|
+
axes: z.array(z.number()).optional(),
|
|
52
|
+
camera: z.string().optional(),
|
|
53
|
+
lookAt: z.string().optional(),
|
|
54
|
+
id: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
server.registerTool('simulate', {
|
|
57
|
+
description: 'Drive gameplay: an ordered batch of steps; returns previews (images) and telemetry (JSON) in order. ' +
|
|
58
|
+
'Each step is one self-contained verb: { do: "play"|"pause"|"stop" | "step"(frames) | "tap"(key) | ' +
|
|
59
|
+
'"keyDown"(key) | "keyUp"(key) | "click"(x,y,button) | "move"(x,y,dx,dy) | "preview"(camera,lookAt) | "telemetry"(id) }. ' +
|
|
60
|
+
'Example: [{do:"play"},{do:"tap",key:"Space"},{do:"step",frames:90},{do:"preview"}].',
|
|
61
|
+
inputSchema: { commands: z.array(simStep) },
|
|
62
|
+
}, ({ commands }) => relay({ id: commandId(), type: 'simulate', steps: commands }));
|
|
63
|
+
return server;
|
|
64
|
+
}
|
|
65
|
+
/** Send a command to the editor and map its reply to MCP tool content. */
|
|
66
|
+
async function relayToEditor(bridge, cmd) {
|
|
67
|
+
let response;
|
|
68
|
+
try {
|
|
69
|
+
response = await bridge.send(cmd);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
|
|
73
|
+
}
|
|
74
|
+
if (!response.ok)
|
|
75
|
+
return { isError: true, content: [{ type: 'text', text: response.error }] };
|
|
76
|
+
return toContent(response.result);
|
|
77
|
+
}
|
|
78
|
+
function toContent(result) {
|
|
79
|
+
switch (result.kind) {
|
|
80
|
+
case 'image': {
|
|
81
|
+
const base64 = result.dataUrl.replace(/^data:image\/png;base64,/, '');
|
|
82
|
+
return { content: [{ type: 'image', data: base64, mimeType: 'image/png' }] };
|
|
83
|
+
}
|
|
84
|
+
case 'json':
|
|
85
|
+
return { content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }] };
|
|
86
|
+
case 'multi': {
|
|
87
|
+
// Flatten each capture's blocks, preserving step order.
|
|
88
|
+
const content = result.results.flatMap((r) => toContent(r).content);
|
|
89
|
+
return { content: content.length > 0 ? content : [{ type: 'text', text: 'ok' }] };
|
|
90
|
+
}
|
|
91
|
+
case 'ok':
|
|
92
|
+
default:
|
|
93
|
+
return { content: [{ type: 'text', text: result.info ?? 'ok' }] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function handle(req, res, bridge) {
|
|
97
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
98
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type, mcp-session-id, mcp-protocol-version');
|
|
99
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
100
|
+
if (req.method === 'OPTIONS') {
|
|
101
|
+
res.statusCode = 204;
|
|
102
|
+
res.end();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if ((req.url ?? '').split('?')[0] !== '/mcp') {
|
|
106
|
+
res.statusCode = 404;
|
|
107
|
+
res.end('not found');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (req.method !== 'POST') {
|
|
111
|
+
res.statusCode = 405;
|
|
112
|
+
res.setHeader('Allow', 'POST');
|
|
113
|
+
res.end(JSON.stringify({ error: 'this MCP endpoint is stateless — use POST' }));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const body = await readJson(req);
|
|
117
|
+
// Stateless: a fresh server + transport per request.
|
|
118
|
+
const server = buildMcpServer(bridge);
|
|
119
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
120
|
+
res.on('close', () => {
|
|
121
|
+
void transport.close();
|
|
122
|
+
void server.close();
|
|
123
|
+
});
|
|
124
|
+
await server.connect(transport);
|
|
125
|
+
await transport.handleRequest(req, res, body);
|
|
126
|
+
}
|
|
127
|
+
function readJson(req) {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
let data = '';
|
|
130
|
+
req.on('data', (c) => (data += c));
|
|
131
|
+
req.on('end', () => {
|
|
132
|
+
try {
|
|
133
|
+
resolve(data ? JSON.parse(data) : undefined);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
resolve(undefined);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function freePort() {
|
|
142
|
+
return new Promise((res, rej) => {
|
|
143
|
+
const probe = createNetServer();
|
|
144
|
+
probe.on('error', rej);
|
|
145
|
+
probe.listen(0, () => {
|
|
146
|
+
const port = probe.address().port;
|
|
147
|
+
probe.close(() => res(port));
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** WebSocket path the editor connects to on the dev server. */
|
|
2
|
+
export declare const WS_PATH = "/scenoco-ws";
|
|
3
|
+
/** A command sent MCP-server → editor (id-correlated with its response). */
|
|
4
|
+
export type EditorCommand = {
|
|
5
|
+
id: string;
|
|
6
|
+
type: 'open';
|
|
7
|
+
path: string;
|
|
8
|
+
} | {
|
|
9
|
+
id: string;
|
|
10
|
+
type: 'preview';
|
|
11
|
+
camera?: string;
|
|
12
|
+
lookAt?: string;
|
|
13
|
+
size?: string;
|
|
14
|
+
} | {
|
|
15
|
+
id: string;
|
|
16
|
+
type: 'run';
|
|
17
|
+
} | {
|
|
18
|
+
id: string;
|
|
19
|
+
type: 'pause';
|
|
20
|
+
} | {
|
|
21
|
+
id: string;
|
|
22
|
+
type: 'stop';
|
|
23
|
+
} | {
|
|
24
|
+
id: string;
|
|
25
|
+
type: 'telemetry';
|
|
26
|
+
targetId: string;
|
|
27
|
+
} | {
|
|
28
|
+
id: string;
|
|
29
|
+
type: 'simulate';
|
|
30
|
+
steps: SimStep[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* One step in a `simulate` batch — transport, frame stepping, synthetic input, or
|
|
34
|
+
* a capture. Executed in order against the live engine, so an agent can drive the
|
|
35
|
+
* game to a precise condition and sample preview/telemetry there.
|
|
36
|
+
*/
|
|
37
|
+
export type SimStep = {
|
|
38
|
+
do: 'play' | 'pause' | 'stop';
|
|
39
|
+
}
|
|
40
|
+
/** Advance the simulation by `frames` ticks (default 1) of `dt` seconds (default 1/60). */
|
|
41
|
+
| {
|
|
42
|
+
do: 'step';
|
|
43
|
+
frames?: number;
|
|
44
|
+
dt?: number;
|
|
45
|
+
}
|
|
46
|
+
/** Keyboard — `key` is a Unity KeyCode name (e.g. 'Space', 'W'). Each action is its own verb. */
|
|
47
|
+
| {
|
|
48
|
+
do: 'tap';
|
|
49
|
+
key: string;
|
|
50
|
+
} | {
|
|
51
|
+
do: 'keyDown';
|
|
52
|
+
key: string;
|
|
53
|
+
} | {
|
|
54
|
+
do: 'keyUp';
|
|
55
|
+
key: string;
|
|
56
|
+
}
|
|
57
|
+
/** Mouse — `x,y` canvas-relative px (top-left). `click` = down+up; button defaults to left. */
|
|
58
|
+
| {
|
|
59
|
+
do: 'click';
|
|
60
|
+
x?: number;
|
|
61
|
+
y?: number;
|
|
62
|
+
button?: 'left' | 'right' | 'middle';
|
|
63
|
+
} | {
|
|
64
|
+
do: 'move';
|
|
65
|
+
x?: number;
|
|
66
|
+
y?: number;
|
|
67
|
+
dx?: number;
|
|
68
|
+
dy?: number;
|
|
69
|
+
}
|
|
70
|
+
/** Gamepad: synthetic pad `index` (default 0) with pressed `buttons` + `axes`. */
|
|
71
|
+
| {
|
|
72
|
+
do: 'gamepad';
|
|
73
|
+
index?: number;
|
|
74
|
+
buttons?: boolean[];
|
|
75
|
+
axes?: number[];
|
|
76
|
+
} | {
|
|
77
|
+
do: 'preview';
|
|
78
|
+
camera?: string;
|
|
79
|
+
lookAt?: string;
|
|
80
|
+
} | {
|
|
81
|
+
do: 'telemetry';
|
|
82
|
+
id: string;
|
|
83
|
+
};
|
|
84
|
+
/** The editor's reply, keyed by the command id. */
|
|
85
|
+
export type EditorResponse = {
|
|
86
|
+
id: string;
|
|
87
|
+
ok: true;
|
|
88
|
+
result: EditorResult;
|
|
89
|
+
} | {
|
|
90
|
+
id: string;
|
|
91
|
+
ok: false;
|
|
92
|
+
error: string;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* An unsolicited server→editor notification (not id-correlated). Sent when a
|
|
96
|
+
* watched file changes on disk: the open scene/prefab (`role: 'open'`) or one of
|
|
97
|
+
* its `src=` dependencies — a prefab/material (`role: 'dep'`).
|
|
98
|
+
*/
|
|
99
|
+
export interface EditorPush {
|
|
100
|
+
push: 'fileChanged';
|
|
101
|
+
/** Absolute path of the changed file. */
|
|
102
|
+
file: string;
|
|
103
|
+
role: 'open' | 'dep';
|
|
104
|
+
}
|
|
105
|
+
/** Result payloads, discriminated by `kind`. */
|
|
106
|
+
export type EditorResult = {
|
|
107
|
+
kind: 'ok';
|
|
108
|
+
info?: string;
|
|
109
|
+
} | {
|
|
110
|
+
kind: 'image';
|
|
111
|
+
dataUrl: string;
|
|
112
|
+
} | {
|
|
113
|
+
kind: 'json';
|
|
114
|
+
data: unknown;
|
|
115
|
+
} | {
|
|
116
|
+
kind: 'multi';
|
|
117
|
+
results: EditorResult[];
|
|
118
|
+
};
|
|
119
|
+
/** Random correlation id for a command. */
|
|
120
|
+
export declare function commandId(): string;
|
|
121
|
+
/**
|
|
122
|
+
* Which compiled element an edit targets. Resolved server-side via the source
|
|
123
|
+
* map (compiled index → source element), so it survives prefab expansion where
|
|
124
|
+
* structural paths don't. `id`s are carried for readability/debugging.
|
|
125
|
+
*/
|
|
126
|
+
export type EditTarget = {
|
|
127
|
+
kind: 'node';
|
|
128
|
+
nodeIndex: number;
|
|
129
|
+
id?: string;
|
|
130
|
+
} | {
|
|
131
|
+
kind: 'component';
|
|
132
|
+
componentIndex: number;
|
|
133
|
+
nodeIndex: number;
|
|
134
|
+
nodeId?: string;
|
|
135
|
+
/** Component tag + its ordinal among same-typed components on the node. */
|
|
136
|
+
type: string;
|
|
137
|
+
ordinal: number;
|
|
138
|
+
} | {
|
|
139
|
+
kind: 'attachment';
|
|
140
|
+
nodeIndex: number;
|
|
141
|
+
/** Owning node id; absent for the scene-root, which `root` then marks. */
|
|
142
|
+
nodeId?: string;
|
|
143
|
+
root?: boolean;
|
|
144
|
+
/** Attachment tag + its ordinal among same-tag attachments on the node. */
|
|
145
|
+
tag: string;
|
|
146
|
+
ordinal: number;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* One attribute change in compiled form (numbers/arrays/strings/bools). The
|
|
150
|
+
* server serializes `value` to an attribute string using the registry codecs,
|
|
151
|
+
* or removes the attribute when it equals the registry default (minimal diff).
|
|
152
|
+
*/
|
|
153
|
+
export interface EditOp {
|
|
154
|
+
target: EditTarget;
|
|
155
|
+
attr: string;
|
|
156
|
+
value: number | boolean | string | number[];
|
|
157
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Shared command/response protocol for the WebSocket bridge between the MCP
|
|
2
|
+
// server (Node) and the editor app (browser). Pure types + a tiny correlation
|
|
3
|
+
// helper, so both ends agree and the relay logic is unit-testable.
|
|
4
|
+
/** WebSocket path the editor connects to on the dev server. */
|
|
5
|
+
export const WS_PATH = '/scenoco-ws';
|
|
6
|
+
/** Random correlation id for a command. */
|
|
7
|
+
export function commandId() {
|
|
8
|
+
return Math.random().toString(36).slice(2, 10);
|
|
9
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type EditorCommand, type EditorPush, type EditorResponse } from './protocol.js';
|
|
2
|
+
export interface ServerOptions {
|
|
3
|
+
/** Directories scanned for `@component`/`@node` tags (default `['src']`). */
|
|
4
|
+
componentRoots?: string[];
|
|
5
|
+
/** Tag packages to register (e.g. `['@scenoco-three/box2d']`). */
|
|
6
|
+
packages?: string[];
|
|
7
|
+
/** Fixed port (default: a free OS port). */
|
|
8
|
+
port?: number;
|
|
9
|
+
/** Base directory `componentRoots` are resolved against (default: `process.cwd()`). */
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Relays MCP tool commands to the connected editor page and awaits its reply. */
|
|
13
|
+
export interface EditorBridge {
|
|
14
|
+
connected(): boolean;
|
|
15
|
+
send(cmd: EditorCommand): Promise<EditorResponse>;
|
|
16
|
+
/** Send an unsolicited notification to the editor page (e.g. a file change). */
|
|
17
|
+
push(msg: EditorPush): void;
|
|
18
|
+
}
|
|
19
|
+
/** Watches the open file + its `src=` deps and pushes change notifications. */
|
|
20
|
+
export interface FileWatcher {
|
|
21
|
+
/** (Re)arm the watch set after a successful compile of `openFile` with `deps`. */
|
|
22
|
+
arm(openFile: string, openContents: string, deps: {
|
|
23
|
+
path: string;
|
|
24
|
+
contents: string;
|
|
25
|
+
}[]): void;
|
|
26
|
+
/** Record content the editor itself just wrote, so its own save isn't seen as external. */
|
|
27
|
+
note(file: string, contents: string): void;
|
|
28
|
+
setPush(push: (msg: EditorPush) => void): void;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface EditorServer {
|
|
32
|
+
/** Local URL the editor app is served at. */
|
|
33
|
+
url: string;
|
|
34
|
+
/** LAN URL (open on another device on the same network). */
|
|
35
|
+
networkUrl?: string;
|
|
36
|
+
bridge: EditorBridge;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Start the editor's Vite dev server: serves the app, registers all project
|
|
41
|
+
* components/packages, compiles any scene/prefab on demand, and bridges the page
|
|
42
|
+
* to the MCP server over a WebSocket.
|
|
43
|
+
*/
|
|
44
|
+
export declare function startEditorServer(opts?: ServerOptions): Promise<EditorServer>;
|