@scenoco-three/editor 0.4.0 → 0.5.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -17
  3. package/app/cameraRig.ts +73 -12
  4. package/app/commands.ts +5 -1
  5. package/app/desktopControls.ts +13 -10
  6. package/app/document.ts +474 -427
  7. package/app/editLog.ts +127 -0
  8. package/app/editor.css +51 -0
  9. package/app/gizmo.ts +100 -6
  10. package/app/index.html +6 -122
  11. package/app/main.ts +488 -304
  12. package/app/meta.ts +24 -0
  13. package/app/picking.ts +47 -0
  14. package/app/session.ts +476 -311
  15. package/app/touchControls.ts +85 -62
  16. package/app/ui-react/AddMemberPopup.tsx +114 -0
  17. package/app/ui-react/BottomLauncher.tsx +15 -0
  18. package/app/ui-react/EditorUI.tsx +22 -0
  19. package/app/ui-react/FilePicker.tsx +80 -0
  20. package/app/ui-react/HeadBtn.tsx +21 -0
  21. package/app/ui-react/InspectorSheet.tsx +475 -0
  22. package/app/ui-react/NodeListSheet.tsx +56 -0
  23. package/app/ui-react/TopBar.tsx +137 -0
  24. package/app/ui-react/Tree.tsx +106 -0
  25. package/app/ui-react/cx.ts +4 -0
  26. package/app/ui-react/fields.tsx +407 -0
  27. package/app/ui-react/format.ts +30 -0
  28. package/app/ui-react/mount.tsx +31 -0
  29. package/app/ui-react/store.ts +80 -0
  30. package/app/ui-react/types.ts +88 -0
  31. package/app/ui-react/ui.module.css +250 -0
  32. package/app/ui-react/useSize.ts +20 -0
  33. package/app/values.ts +137 -0
  34. package/dist/cli/index.js +93 -70
  35. package/dist/index.d.ts +10 -3
  36. package/dist/index.js +9 -5
  37. package/dist/mcp.d.ts +28 -12
  38. package/dist/mcp.js +172 -131
  39. package/dist/protocol.d.ts +74 -65
  40. package/dist/protocol.js +17 -4
  41. package/dist/server.d.ts +106 -39
  42. package/dist/server.js +386 -361
  43. package/package.json +25 -10
  44. package/app/filePicker.ts +0 -64
  45. package/app/panels.ts +0 -553
  46. package/app/toolbar.ts +0 -131
  47. package/app/wsClient.ts +0 -92
  48. package/dist/args.d.ts +0 -8
  49. package/dist/args.js +0 -28
package/dist/cli/index.js CHANGED
@@ -1,99 +1,122 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { resolve } from 'node:path';
4
- import { parseArgs, parseList } from '../args.js';
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, join, resolve } from 'node:path';
5
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
6
+ import { createMcpServer, startMcpHttp } from '../mcp.js';
7
+ const USAGE = `scenoco-editor — open a scene and drive it from an MCP client
8
8
 
9
9
  Usage:
10
- scenoco-editor [scene|prefab] [--port <n>] [--no-open]
10
+ scenoco-editor [document] [options]
11
11
 
12
- With no file, the editor opens a picker listing the scenes/prefabs in the
13
- project's assets/ folder. Components are scanned from that assets/ folder and tag
14
- packages are read from its package.json — no --components/--packages needed.
12
+ With no document, the editor opens a picker listing the project's scenes and
13
+ prefabs. Tag packages are read from the project's package.json, so --modules is
14
+ only for compiling against something other than what the project depends on.
15
15
 
16
16
  Options:
17
- --port <n> Fixed MCP server port — set this so the endpoint an MCP client
18
- connects to is stable across restarts (the editor's own HTTP
19
- server always takes a free port; it doesn't need to be fixed)
20
- --no-open Don't auto-open the editor in a browser
17
+ --root <dir> Project root (default: the nearest one at or above the
18
+ current directory)
19
+ --modules a,b Packages whose tags the documents use, beyond three
20
+ --mcp-port <n> Fixed MCP port (default 55470) — an MCP client is configured
21
+ once with a URL, so this must not move between restarts
21
22
 
22
- Starts a local editor and an MCP server. The editor opens in your default
23
- browser; when you stop the server (Ctrl+C) it tells the page to close its own
24
- tab, so no stale tab is left behind. Point an MCP client at the printed
25
- endpoint; its tools — open_file, get_preview, run, pause, stop, get_telemetry —
26
- drive the live editor.`;
23
+ The editor serves its own page and imports the project's generated modules, so
24
+ what you see is the same scene the game loads. Its MCP tools **observe**: open,
25
+ screenshot, telemetry, play/pause/stop, simulate. Documents are changed by
26
+ editing the XML — in the editor, or in your own editor — and the watcher rebuilds.`;
27
+ const DEFAULT_MCP_PORT = 55470;
27
28
  async function main(argv) {
28
- const { positionals, options } = parseArgs(argv);
29
- const file = positionals[0];
30
- if (file === '-h' || file === '--help') {
29
+ if (argv.includes('-h') || argv.includes('--help')) {
31
30
  console.log(USAGE);
32
31
  return 0;
33
32
  }
34
- // npm/npx rewrites process.cwd() to the nearest enclosing package.json when
35
- // run from inside a workspace package without its own node_modules/.bin
36
- // (e.g. `npx scenoco-editor` from examples/breakout/scenes). INIT_CWD still
37
- // holds the shell's actual directory.
38
- const cwd = process.env.INIT_CWD || process.cwd();
39
- // Components default to assets/ and tag packages to the project's package.json
40
- // deps (see startEditorServer); --components/--packages remain as optional
41
- // overrides for non-standard layouts.
42
- // The editor's HTTP server can float on any free port — the browser is pointed
43
- // at whatever it gets. `--port` fixes the MCP endpoint instead, so an MCP
44
- // client's configured URL stays valid across editor restarts.
33
+ const flag = (name) => {
34
+ const i = argv.indexOf(`--${name}`);
35
+ return i === -1 ? undefined : argv[i + 1];
36
+ };
37
+ const document = argv.find((a) => !a.startsWith('--') && argv[argv.indexOf(a) - 1]?.startsWith('--') !== true);
38
+ const modules = flag('modules')?.split(',').filter(Boolean);
39
+ // npm and npx rewrite `cwd` to the nearest enclosing package.json when run from inside a
40
+ // workspace package; `INIT_CWD` still holds the directory the shell was actually in.
41
+ const from = flag('root') ?? process.env.INIT_CWD ?? process.cwd();
42
+ const root = flag('root') ? resolve(from) : projectRootAt(from);
43
+ let closing = false;
45
44
  const editor = await startEditorServer({
46
- cwd,
47
- ...(parseList(options.components) ? { componentRoots: parseList(options.components) } : {}),
48
- ...(parseList(options.packages) ? { packages: parseList(options.packages) } : {}),
49
- });
50
- const mcp = await startMcpServer(editor.bridge, {
51
- ...(options.port ? { port: Number(options.port) } : {}),
45
+ root,
46
+ ...(modules ? { modules: ['three', ...modules] } : {}),
47
+ // Self-halt once every tab is closed, so a spawned editor never lingers holding its ports.
48
+ onIdle: () => {
49
+ if (closing)
50
+ return;
51
+ console.error('\n no editor connected — shutting down so no stale process is left behind\n');
52
+ void shutdown();
53
+ },
52
54
  });
53
- // With no file, open bare — the editor page shows a picker of the assets/ scenes.
54
- const query = file ? `?file=${encodeURIComponent(resolve(cwd, file))}` : '';
55
- const appUrl = `${editor.url}${query}`;
56
- console.log(`\n editor: ${appUrl}`);
55
+ const mcpPort = Number(flag('mcp-port') ?? DEFAULT_MCP_PORT);
56
+ // A server per session: an MCP client that reconnects gets a fresh one rather than a 400.
57
+ const mcp = await startMcpHttp(() => createMcpServer(editor.bridge, () => editor.url), mcpPort);
58
+ const query = document ? `?path=${encodeURIComponent(document)}` : '';
59
+ const url = `${editor.url}${query}`;
60
+ console.log(`\n editor: ${url}`);
61
+ // The address to type into a phone on the same Wi-Fi — the touch controls only mean anything
62
+ // on a device with fingers.
57
63
  if (editor.networkUrl)
58
64
  console.log(` wi-fi: ${editor.networkUrl}${query}`);
59
- console.log(` mcp: ${mcp.url} ← add this to your MCP client (transport: http)`);
60
- console.log(`\n press Ctrl+C to stop\n`);
61
- if (!('no-open' in options))
62
- openBrowser(appUrl);
63
- let closing = false;
65
+ console.log(` mcp: http://localhost:${mcpPort} ← add this to your MCP client (transport: http)`);
66
+ console.log('\n press Ctrl+C to stop\n');
64
67
  const shutdown = async () => {
65
68
  if (closing)
66
69
  return;
67
70
  closing = true;
68
- // Tell the editor page to close its own tab, then give the notification a
69
- // moment to reach the browser before we tear the socket down. Works in any
70
- // browser/OS (the tab was opened with the platform's default `open`), though
71
- // window.close() is best-effort — see the app's wsClient for the fallback.
72
- editor.bridge.push({ push: 'shutdown' });
73
- await new Promise((r) => setTimeout(r, 250).unref());
74
- // Connected browsers keep WS/HMR/keep-alive sockets open, so `close()` can
75
- // stall waiting for them to drain — cap it so Ctrl+C always exits promptly.
76
- const deadline = new Promise((r) => setTimeout(r, 1500).unref());
77
- await Promise.race([Promise.all([mcp.close().catch(() => { }), editor.close().catch(() => { })]), deadline]);
71
+ // A connected browser holds WebSocket and HMR sockets open, so `close()` can stall waiting
72
+ // for them to drain. Cap it, so Ctrl+C always exits promptly.
73
+ const deadline = new Promise((done) => setTimeout(done, 1500).unref());
74
+ await Promise.race([
75
+ Promise.all([mcp.close().catch(() => { }), editor.close().catch(() => { })]),
76
+ deadline,
77
+ ]);
78
78
  process.exit(0);
79
79
  };
80
80
  process.on('SIGINT', () => void shutdown());
81
81
  process.on('SIGTERM', () => void shutdown());
82
- await new Promise(() => { }); // keep alive
83
- return 0;
84
- }
85
- /** Open a URL in the default browser (best-effort, per platform). */
86
- function openBrowser(url) {
87
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
88
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
89
- try {
90
- spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
82
+ // Having reclaimed the previous session's port, give any tab left behind by an ungraceful exit
83
+ // a moment to reattach on its own retry loop — a restart should land in the tab that is
84
+ // already open rather than stack a second one beside it. On a fresh port nothing is waiting.
85
+ const reattached = editor.reusedPort && (await editor.bridge.waitForConnection(1500));
86
+ if (reattached) {
87
+ console.log(' reattached the editor tab already open — no new tab\n');
88
+ // That tab is still showing whatever it had open, so a named document has to be asked for.
89
+ if (document)
90
+ await editor.bridge.send({ id: 'cli-open', type: 'open', path: document }).catch(() => { });
91
+ }
92
+ else {
93
+ openBrowser(url);
91
94
  }
92
- catch {
93
- /* non-fatal: the URL is printed */
95
+ return new Promise(() => { }); // runs until interrupted
96
+ }
97
+ /**
98
+ * The project a bare `scenoco-editor` means: the nearest directory at or above `from` that holds
99
+ * a `tsconfig.json`.
100
+ *
101
+ * Running it from `src/` or `assets/scenes/` is the normal way to reach for an editor, and
102
+ * "no tsconfig.json here" is a poor answer when the project is one directory up. Falls back to
103
+ * `from` unchanged, so the error still names the place the person actually was.
104
+ */
105
+ function projectRootAt(from) {
106
+ for (let dir = resolve(from);; dir = dirname(dir)) {
107
+ if (existsSync(join(dir, 'tsconfig.json')))
108
+ return dir;
109
+ if (dirname(dir) === dir)
110
+ return resolve(from);
94
111
  }
95
112
  }
96
- process.exitCode = await main(process.argv.slice(2)).catch((e) => {
97
- console.error(e instanceof Error ? e.message : String(e));
98
- return 1;
113
+ function openBrowser(url) {
114
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
115
+ spawn(command, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
116
+ }
117
+ main(process.argv.slice(2)).then((code) => {
118
+ process.exitCode = code;
119
+ }, (error) => {
120
+ console.error(error instanceof Error ? error.message : String(error));
121
+ process.exitCode = 1;
99
122
  });
package/dist/index.d.ts CHANGED
@@ -1,3 +1,10 @@
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';
1
+ /**
2
+ * `@scenoco-three/editor` — the programmatic API behind the `scenoco-editor` CLI.
3
+ *
4
+ * Serves the editor page, bridges it to an MCP server over a WebSocket, and exposes MCP tools
5
+ * that **observe**: open, screenshot, telemetry, play/pause/stop, simulate. Documents are changed
6
+ * by editing the XML; there is no remote edit tool, by decision.
7
+ */
8
+ export { startEditorServer, applyEdits, type ServerOptions, type EditorServer, type EditorBridge, type Edit, } from './server.js';
9
+ export { createMcpServer, startMcpHttp } from './mcp.js';
10
+ export { WS_PATH, commandId, type EditorCommand, type EditorPush, type EditorResponse, type EditorResult, type PreviewFrame, type SimStep, } from './protocol.js';
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
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';
1
+ /**
2
+ * `@scenoco-three/editor` — the programmatic API behind the `scenoco-editor` CLI.
3
+ *
4
+ * Serves the editor page, bridges it to an MCP server over a WebSocket, and exposes MCP tools
5
+ * that **observe**: open, screenshot, telemetry, play/pause/stop, simulate. Documents are changed
6
+ * by editing the XML; there is no remote edit tool, by decision.
7
+ */
8
+ export { startEditorServer, applyEdits, } from './server.js';
9
+ export { createMcpServer, startMcpHttp } from './mcp.js';
6
10
  export { WS_PATH, commandId, } from './protocol.js';
package/dist/mcp.d.ts CHANGED
@@ -1,16 +1,32 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
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
3
  /**
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.
4
+ * The MCP surface: **eyes, not hands.**
5
+ *
6
+ * Open a document, render it, drive it, read what happened. There is no tool here that writes to
7
+ * the user's source, and that is a decision rather than an omission. An agent already has file
8
+ * tools, and the editor's watcher rebuilds whatever it writes — so an edit tool would have been
9
+ * a second write path into someone's project that never passed through their editor, their diff
10
+ * or their undo. An agent holding one can reshape a scene while believing it is only looking at
11
+ * it.
12
+ *
13
+ * v1 shipped `patch`, `structural` and `save` tools alongside these. They are deleted.
14
+ *
15
+ * The one thing worth insisting on: `simulate` exists because a screenshot of frame zero says
16
+ * almost nothing about whether a game works. Drive it to the interesting moment, *then* look.
17
+ */
18
+ export declare function createMcpServer(bridge: EditorBridge, editorUrl: () => string): McpServer;
19
+ /**
20
+ * Serve the MCP over HTTP on a fixed port.
21
+ *
22
+ * Fixed on purpose: an MCP client is configured once with a URL, and a port that moved on every
23
+ * restart would need reconfiguring every time.
24
+ *
25
+ * **One transport per session**, created when a client initializes and kept until it goes away.
26
+ * A single shared transport looked simpler and allowed exactly one client ever: the first
27
+ * `initialize` claimed it, and every later one — a reconnect, a second client, a restarted
28
+ * agent — got a bare `400`, which reads like a crashed server rather than a design limit.
11
29
  */
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;
30
+ export declare function startMcpHttp(makeServer: () => McpServer, port: number): Promise<{
31
+ close(): Promise<void>;
32
+ }>;
package/dist/mcp.js CHANGED
@@ -1,150 +1,191 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { createServer as createHttpServer } from 'node:http';
2
- import { createServer as createNetServer } from 'node:net';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
5
  import { z } from 'zod';
6
6
  import { commandId } from './protocol.js';
7
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.
8
+ * The MCP surface: **eyes, not hands.**
9
+ *
10
+ * Open a document, render it, drive it, read what happened. There is no tool here that writes to
11
+ * the user's source, and that is a decision rather than an omission. An agent already has file
12
+ * tools, and the editor's watcher rebuilds whatever it writes — so an edit tool would have been
13
+ * a second write path into someone's project that never passed through their editor, their diff
14
+ * or their undo. An agent holding one can reshape a scene while believing it is only looking at
15
+ * it.
16
+ *
17
+ * v1 shipped `patch`, `structural` and `save` tools alongside these. They are deleted.
18
+ *
19
+ * The one thing worth insisting on: `simulate` exists because a screenshot of frame zero says
20
+ * almost nothing about whether a game works. Drive it to the interesting moment, *then* look.
10
21
  */
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())),
22
+ export function createMcpServer(bridge, editorUrl) {
23
+ const mcp = new McpServer({ name: 'scenoco-editor', version: '2.0.0' });
24
+ /** Every tool needs a live page; saying so plainly beats a timeout. */
25
+ const send = async (command) => {
26
+ if (!bridge.connected()) {
27
+ throw new Error(`no editor page is open — visit ${editorUrl()} first, then retry`);
28
+ }
29
+ const response = await bridge.send(command);
30
+ if (!response.ok)
31
+ throw new Error(response.error);
32
+ return response.result;
18
33
  };
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.',
34
+ const frameSchema = {
35
+ width: z.number().int().positive().describe('Captured image width in pixels'),
36
+ height: z.number().int().positive().describe('Captured image height in pixels'),
37
+ ui: z
38
+ .boolean()
39
+ .describe('Include the screen-space UI layer, framing the shot through the camera that hosts it'),
40
+ };
41
+ const POINT = '"y: 6; z: 12" — the document\'s own vector grammar; an omitted axis is 0';
42
+ mcp.registerTool('open', {
43
+ description: 'Open a scene or prefab document in the editor page.',
44
+ inputSchema: { path: z.string().describe('Project-relative path, e.g. assets/scenes/main.scene.xml') },
45
+ }, async ({ path }) => text(await send({ id: commandId(), type: 'open', path })));
46
+ mcp.registerTool('screenshot', {
47
+ description: 'Render one PNG of the open scene from a given viewpoint. Does not move the editor camera.',
27
48
  inputSchema: {
28
- camera: z.string().optional().describe('Camera position "x y z"'),
29
- lookAt: z.string().optional().describe('Look-at point "x y z"'),
49
+ camera: z.string().describe(`Where to look from: ${POINT}, or "fit" to frame the whole scene`),
50
+ lookAt: z.string().optional().describe(`What to look at: ${POINT}. Defaults to the origin`),
51
+ ...frameSchema,
30
52
  },
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(),
53
+ }, async ({ camera, lookAt, width, height, ui }) => image(await send({
54
+ id: commandId(),
55
+ type: 'preview',
56
+ camera,
57
+ ...(lookAt ? { lookAt } : {}),
58
+ frame: { width, height, ui },
59
+ })));
60
+ mcp.registerTool('telemetry', {
61
+ description: 'Read the live state at an id the document declares — transform, visibility, and each component’s public values.',
62
+ inputSchema: { id: z.string().describe('An id= from the open document') },
63
+ }, async ({ id }) => text(await send({ id: commandId(), type: 'telemetry', targetId: id })));
64
+ for (const [name, type] of [
65
+ ['play', 'run'],
66
+ ['pause', 'pause'],
67
+ ['stop', 'stop'],
68
+ ]) {
69
+ mcp.registerTool(name, { description: `${name[0].toUpperCase()}${name.slice(1)} the open scene.`, inputSchema: {} }, async () => text(await send({ id: commandId(), type })));
70
+ }
71
+ mcp.registerTool('simulate', {
72
+ description: 'Run a batch of steps against the live scene and collect what each produced. This is how ' +
73
+ 'to find out whether a game works: drive it to the moment that matters, then capture. ' +
74
+ 'Steps run in order.',
75
+ inputSchema: {
76
+ steps: z
77
+ .array(z.record(z.string(), z.unknown()))
78
+ .describe('Ordered steps. {"do":"play"} · {"do":"step","frames":30,"dt":0.0167} · ' +
79
+ '{"do":"press","key":"Space"} (a KeyCode member name) · {"do":"keyDown"/"keyUp","key":…} · ' +
80
+ '{"do":"click","x":100,"y":80} · {"do":"move","x":…,"y":…} · ' +
81
+ `{"do":"preview","camera":${JSON.stringify(POINT)}} · {"do":"telemetry","id":"ball"}`),
82
+ ...frameSchema,
83
+ },
84
+ }, async ({ steps, width, height, ui }) => {
85
+ const result = await send({
86
+ id: commandId(),
87
+ type: 'simulate',
88
+ steps: steps,
89
+ frame: { width, height, ui },
90
+ });
91
+ if (result.kind !== 'multi')
92
+ return text(result);
93
+ // Images and readings interleaved in the order they were captured, so a caller can line a
94
+ // frame up with the numbers that went with it.
95
+ return {
96
+ content: result.results.map((one) => one.kind === 'image'
97
+ ? { type: 'image', data: one.dataUrl.split(',')[1] ?? '', mimeType: 'image/png' }
98
+ : { type: 'text', text: JSON.stringify(one.kind === 'json' ? one.data : one, null, 2) }),
99
+ };
55
100
  });
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;
101
+ return mcp;
64
102
  }
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);
103
+ function text(result) {
104
+ const body = result.kind === 'json'
105
+ ? JSON.stringify(result.data, null, 2)
106
+ : result.kind === 'ok'
107
+ ? (result.info ?? 'ok')
108
+ : JSON.stringify(result);
109
+ return { content: [{ type: 'text', text: body }] };
77
110
  }
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' }] };
111
+ function image(result) {
112
+ if (result.kind !== 'image')
113
+ throw new Error('the editor did not return an image');
114
+ return {
115
+ content: [{ type: 'image', data: result.dataUrl.split(',')[1] ?? '', mimeType: 'image/png' }],
116
+ };
117
+ }
118
+ /**
119
+ * Serve the MCP over HTTP on a fixed port.
120
+ *
121
+ * Fixed on purpose: an MCP client is configured once with a URL, and a port that moved on every
122
+ * restart would need reconfiguring every time.
123
+ *
124
+ * **One transport per session**, created when a client initializes and kept until it goes away.
125
+ * A single shared transport looked simpler and allowed exactly one client ever: the first
126
+ * `initialize` claimed it, and every later one — a reconnect, a second client, a restarted
127
+ * agent — got a bare `400`, which reads like a crashed server rather than a design limit.
128
+ */
129
+ export async function startMcpHttp(makeServer, port) {
130
+ const sessions = new Map();
131
+ const http = createHttpServer((req, res) => {
132
+ void handle(req, res).catch((error) => {
133
+ res.statusCode = 500;
134
+ res.end(String(error instanceof Error ? error.message : error));
135
+ });
136
+ });
137
+ async function handle(req, res) {
138
+ const id = req.headers['mcp-session-id'];
139
+ const existing = typeof id === 'string' ? sessions.get(id) : undefined;
140
+ if (existing)
141
+ return existing.handleRequest(req, res, await body(req));
142
+ const parsed = await body(req);
143
+ if (!isInitialize(parsed)) {
144
+ res.statusCode = 400;
145
+ res.setHeader('Content-Type', 'application/json');
146
+ res.end(JSON.stringify({
147
+ jsonrpc: '2.0',
148
+ id: null,
149
+ error: { code: -32000, message: 'unknown session — send `initialize` first' },
150
+ }));
151
+ return;
90
152
  }
91
- case 'ok':
92
- default:
93
- return { content: [{ type: 'text', text: result.info ?? 'ok' }] };
153
+ const transport = new StreamableHTTPServerTransport({
154
+ sessionIdGenerator: () => randomUUID(),
155
+ onsessioninitialized: (session) => {
156
+ sessions.set(session, transport);
157
+ },
158
+ });
159
+ transport.onclose = () => {
160
+ if (transport.sessionId)
161
+ sessions.delete(transport.sessionId);
162
+ };
163
+ await makeServer().connect(transport);
164
+ return transport.handleRequest(req, res, parsed);
94
165
  }
166
+ await new Promise((done) => http.listen(port, done));
167
+ return {
168
+ close: async () => {
169
+ for (const transport of sessions.values())
170
+ await transport.close().catch(() => { });
171
+ sessions.clear();
172
+ await new Promise((done) => http.close(() => done()));
173
+ },
174
+ };
95
175
  }
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;
176
+ function isInitialize(parsed) {
177
+ return typeof parsed === 'object' && parsed !== null && parsed.method === 'initialize';
178
+ }
179
+ /** The request body, read once — the transport is handed the parsed value rather than the stream. */
180
+ async function body(req) {
181
+ const chunks = [];
182
+ for await (const chunk of req)
183
+ chunks.push(chunk);
184
+ const raw = Buffer.concat(chunks).toString('utf8');
185
+ try {
186
+ return raw ? JSON.parse(raw) : undefined;
109
187
  }
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;
188
+ catch {
189
+ return undefined;
115
190
  }
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
191
  }