@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.
- package/LICENSE +21 -0
- package/README.md +23 -17
- package/app/cameraRig.ts +73 -12
- package/app/commands.ts +5 -1
- package/app/desktopControls.ts +13 -10
- package/app/document.ts +474 -427
- package/app/editLog.ts +127 -0
- package/app/editor.css +51 -0
- package/app/gizmo.ts +100 -6
- package/app/index.html +6 -122
- package/app/main.ts +488 -304
- package/app/meta.ts +24 -0
- package/app/picking.ts +47 -0
- package/app/session.ts +476 -311
- package/app/touchControls.ts +85 -62
- package/app/ui-react/AddMemberPopup.tsx +114 -0
- package/app/ui-react/BottomLauncher.tsx +15 -0
- package/app/ui-react/EditorUI.tsx +22 -0
- package/app/ui-react/FilePicker.tsx +80 -0
- package/app/ui-react/HeadBtn.tsx +21 -0
- package/app/ui-react/InspectorSheet.tsx +475 -0
- package/app/ui-react/NodeListSheet.tsx +56 -0
- package/app/ui-react/TopBar.tsx +137 -0
- package/app/ui-react/Tree.tsx +106 -0
- package/app/ui-react/cx.ts +4 -0
- package/app/ui-react/fields.tsx +407 -0
- package/app/ui-react/format.ts +30 -0
- package/app/ui-react/mount.tsx +31 -0
- package/app/ui-react/store.ts +80 -0
- package/app/ui-react/types.ts +88 -0
- package/app/ui-react/ui.module.css +250 -0
- package/app/ui-react/useSize.ts +20 -0
- package/app/values.ts +137 -0
- package/dist/cli/index.js +93 -70
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -5
- package/dist/mcp.d.ts +28 -12
- package/dist/mcp.js +172 -131
- package/dist/protocol.d.ts +74 -65
- package/dist/protocol.js +17 -4
- package/dist/server.d.ts +106 -39
- package/dist/server.js +386 -361
- package/package.json +25 -10
- package/app/filePicker.ts +0 -64
- package/app/panels.ts +0 -553
- package/app/toolbar.ts +0 -131
- package/app/wsClient.ts +0 -92
- package/dist/args.d.ts +0 -8
- 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 {
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { startEditorServer } from '../server.js';
|
|
6
|
-
import {
|
|
7
|
-
const USAGE = `scenoco-editor — open a scene
|
|
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 [
|
|
10
|
+
scenoco-editor [document] [options]
|
|
11
11
|
|
|
12
|
-
With no
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
--
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
--
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
...(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
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:
|
|
60
|
-
console.log(
|
|
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
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
|
|
14
|
-
}
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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().
|
|
29
|
-
lookAt: z.string().optional().describe(
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
}
|