@chocodrop/mcp 0.1.0-alpha.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/bin/chocodrop-mcp.js +13 -0
  4. package/package.json +42 -0
  5. package/runtime/.chocodrop-mcp-runtime +1 -0
  6. package/runtime/asset-bridge.js +460 -0
  7. package/runtime/mcp-server.js +107 -0
  8. package/runtime/site/examples/basic/README.md +38 -0
  9. package/runtime/site/examples/basic/index.html +1730 -0
  10. package/runtime/site/examples/bookmarklet-v2.html +141 -0
  11. package/runtime/site/examples/bookmarklet.html +126 -0
  12. package/runtime/site/examples/lofi-room/index.html +2570 -0
  13. package/runtime/site/examples/lofi-room/sandbox-lite.html +124 -0
  14. package/runtime/site/examples/music-garden/index.html +2150 -0
  15. package/runtime/site/examples/pixel-ocean/index.html +1780 -0
  16. package/runtime/site/examples/react-three-fiber/AdvancedExample.jsx +338 -0
  17. package/runtime/site/examples/react-three-fiber/App.jsx +97 -0
  18. package/runtime/site/examples/react-three-fiber/README.md +294 -0
  19. package/runtime/site/examples/react-three-fiber/package.json +25 -0
  20. package/runtime/site/examples/sdk-test/README.md +70 -0
  21. package/runtime/site/examples/space/index.html +1975 -0
  22. package/runtime/site/examples/toy-city/index.html +2288 -0
  23. package/runtime/site/examples/wabi-sabi/index.html +2515 -0
  24. package/runtime/site/getting-started.html +84 -0
  25. package/runtime/site/index.html +1370 -0
  26. package/runtime/site/public/CommandUI.js +6341 -0
  27. package/runtime/site/public/LiveCommandClient.js +432 -0
  28. package/runtime/site/public/SceneManager.js +5493 -0
  29. package/runtime/site/public/bookmarklet.js +206 -0
  30. package/runtime/site/public/bootstrap.js +50 -0
  31. package/runtime/site/public/chocodrop-demo.umd.js +24020 -0
  32. package/runtime/site/public/chocodrop-demo.umd.min.js +24020 -0
  33. package/runtime/site/public/chocodrop.esm.js +2 -0
  34. package/runtime/site/public/html-sandbox/frame.js +865 -0
  35. package/runtime/site/public/icons/icon-192.png +0 -0
  36. package/runtime/site/public/icons/icon-512.png +0 -0
  37. package/runtime/site/public/icons/icon-maskable-512.png +0 -0
  38. package/runtime/site/public/immersive-launcher.js +175 -0
  39. package/runtime/site/public/immersive.html +165 -0
  40. package/runtime/site/public/index.html +1011 -0
  41. package/runtime/site/public/index.js +9 -0
  42. package/runtime/site/public/load-chocodrop.js +25 -0
  43. package/runtime/site/public/load-three.js +26 -0
  44. package/runtime/site/public/manifest.webmanifest +59 -0
  45. package/runtime/site/public/pwa-bootstrap.js +128 -0
  46. package/runtime/site/public/sample-card.svg +15 -0
  47. package/runtime/site/public/service-worker.js +117 -0
  48. package/runtime/site/public/translation-dictionary.js +257 -0
  49. package/runtime/site/public/xr/xr-ui.css +338 -0
  50. package/runtime/site/public/xr-viewer.html +263 -0
  51. package/runtime/site/src/client/local-bridge.js +83 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ChocoDrop Project
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # @chocodrop/mcp
2
+
3
+ Codex、Claude Code、AntigravityなどのMCPクライアントから、許可したローカルフォルダ内の画像・動画・GLBをChocoDropのブラウザシーンへ配置します。
4
+
5
+ このMCPは素材を生成しません。普段使っている生成MCPやCLIでファイルを作り、素材フォルダへ保存してから配置します。
6
+
7
+ ```bash
8
+ pnpm dlx @chocodrop/mcp@0.1.0-alpha.0 --assets-dir "$HOME/ChocoDropAssets"
9
+ ```
10
+
11
+ 通常は [`@chocodrop/setup`](https://www.npmjs.com/package/@chocodrop/setup) で利用中のCLIへ登録してください。
12
+
13
+ - 待受先は`127.0.0.1`のみです。
14
+ - `get_status`で専用ブラウザURLを取得します。
15
+ - `import_asset`でPNG、JPEG、WebP、MP4、WebM、自己完結したGLBを配置します。
16
+ - 1ファイル32MiB、プロセス合計128MiBまでです。
17
+ - パッケージに`install`・`postinstall`スクリプトはありません。
18
+
19
+ [Website](https://nyukicorn.github.io/chocodrop/) · [Source](https://github.com/nyukicorn/chocodrop)
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { runMcpServer } from '../runtime/mcp-server.js';
5
+
6
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+
8
+ runMcpServer(process.argv.slice(2), { staticRoot: path.join(packageRoot, 'runtime', 'site') }).catch(
9
+ (error) => {
10
+ process.stderr.write(`${error.message}\n`);
11
+ process.exitCode = 1;
12
+ }
13
+ );
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@chocodrop/mcp",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Place local images, videos, and GLB files into a ChocoDrop browser scene through MCP.",
5
+ "type": "module",
6
+ "bin": {
7
+ "chocodrop-mcp": "./bin/chocodrop-mcp.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "runtime/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "prepack": "node ../../scripts/build-mcp-package.mjs"
17
+ },
18
+ "keywords": [
19
+ "chocodrop",
20
+ "mcp",
21
+ "threejs",
22
+ "codex",
23
+ "claude-code",
24
+ "antigravity"
25
+ ],
26
+ "author": {
27
+ "name": "ChocoDrop Project",
28
+ "email": "chocodrop.dev@gmail.com"
29
+ },
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "tag": "alpha"
34
+ },
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.17.5",
40
+ "ws": "^8.18.3"
41
+ }
42
+ }
@@ -0,0 +1 @@
1
+ ChocoDrop MCP runtime
@@ -0,0 +1,460 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { constants as fsConstants, promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { WebSocket, WebSocketServer } from 'ws';
7
+
8
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
9
+ const MAX_ASSET_BYTES = 32 * 1024 * 1024;
10
+ const MAX_TOTAL_ASSET_BYTES = 128 * 1024 * 1024;
11
+ const ACK_TIMEOUT_MS = 20_000;
12
+ const ASSET_TYPES = new Map([
13
+ ['.png', 'image/png'],
14
+ ['.jpg', 'image/jpeg'],
15
+ ['.jpeg', 'image/jpeg'],
16
+ ['.webp', 'image/webp'],
17
+ ['.mp4', 'video/mp4'],
18
+ ['.webm', 'video/webm'],
19
+ ['.glb', 'model/gltf-binary'],
20
+ ]);
21
+ const STATIC_ROOTS = [
22
+ 'public',
23
+ 'dist',
24
+ 'vendor',
25
+ 'examples',
26
+ 'src/client',
27
+ 'src/shared',
28
+ 'src/common',
29
+ ];
30
+ const STATIC_FILES = new Set(['index.html', 'getting-started.html']);
31
+ const BLOCKED_STATIC_SEGMENTS = new Set(['node_modules', 'generated', 'imported-scenes']);
32
+ function isInside(child, parent) {
33
+ const relative = path.relative(parent, child);
34
+ return (
35
+ relative &&
36
+ !relative.startsWith(`..${path.sep}`) &&
37
+ relative !== '..' &&
38
+ !path.isAbsolute(relative)
39
+ );
40
+ }
41
+ function assetMime(file) {
42
+ return ASSET_TYPES.get(path.extname(file).toLowerCase()) || null;
43
+ }
44
+ function assetKind(file) {
45
+ const extension = path.extname(file).toLowerCase();
46
+ return extension === '.glb'
47
+ ? 'model'
48
+ : extension === '.mp4' || extension === '.webm'
49
+ ? 'video'
50
+ : 'image';
51
+ }
52
+ function validPosition(value) {
53
+ if (value === undefined) return { x: 0, y: 3, z: 0 };
54
+ if (!value || typeof value !== 'object')
55
+ throw new Error('position must be an object with finite x, y, z values');
56
+ const result = {};
57
+ for (const key of ['x', 'y', 'z']) {
58
+ if (!Number.isFinite(value[key])) throw new Error(`position.${key} must be a finite number`);
59
+ result[key] = value[key];
60
+ }
61
+ return result;
62
+ }
63
+ function assertSafeGlb(buffer) {
64
+ if (
65
+ buffer.length < 20 ||
66
+ buffer.readUInt32LE(0) !== 0x46546c67 ||
67
+ buffer.readUInt32LE(8) !== buffer.length ||
68
+ buffer.readUInt32LE(16) !== 0x4e4f534a
69
+ )
70
+ throw new Error('GLB is invalid');
71
+ let document;
72
+ try {
73
+ document = JSON.parse(
74
+ buffer
75
+ .subarray(20, 20 + buffer.readUInt32LE(12))
76
+ .toString('utf8')
77
+ .replace(/\0+$/, '')
78
+ );
79
+ } catch {
80
+ throw new Error('GLB JSON chunk is invalid');
81
+ }
82
+ const visit = (value) => {
83
+ if (!value || typeof value !== 'object') return;
84
+ if (typeof value.uri === 'string' && !value.uri.startsWith('data:'))
85
+ throw new Error('GLB external URI references are not allowed');
86
+ for (const child of Object.values(value)) visit(child);
87
+ };
88
+ visit(document);
89
+ }
90
+
91
+ /** Local-only, token-protected bridge from one stdio MCP client to one browser scene. */
92
+ export class LocalAssetBridge {
93
+ constructor({
94
+ assetsDir,
95
+ port = 0,
96
+ host = '127.0.0.1',
97
+ logger = console,
98
+ staticRoot = ROOT,
99
+ } = {}) {
100
+ if (!assetsDir) throw new Error('--assets-dir is required');
101
+ if (host !== '127.0.0.1') throw new Error('LocalAssetBridge only listens on 127.0.0.1');
102
+ this.assetsDirInput = path.resolve(assetsDir);
103
+ this.port = port;
104
+ this.host = host;
105
+ this.logger = logger;
106
+ this.staticRoot = path.resolve(staticRoot);
107
+ this.token = randomBytes(32).toString('base64url');
108
+ this.assets = new Map();
109
+ this.totalAssetBytes = 0;
110
+ this.clients = new Set();
111
+ this.pending = new Map();
112
+ this.server = null;
113
+ this.wss = null;
114
+ this.assetsDir = null;
115
+ this.stopping = null;
116
+ }
117
+ get browserUrl() {
118
+ return `http://${this.host}:${this.port}/examples/basic/?local=1#token=${this.token}`;
119
+ }
120
+ getStatus() {
121
+ return {
122
+ host: this.host,
123
+ port: this.port,
124
+ browserUrl: this.browserUrl,
125
+ connected: this.clients.size,
126
+ ready: this.clients.size === 1,
127
+ assetsDir: this.assetsDirInput,
128
+ maxAssetBytes: MAX_ASSET_BYTES,
129
+ maxTotalAssetBytes: MAX_TOTAL_ASSET_BYTES,
130
+ };
131
+ }
132
+ async start() {
133
+ const stat = await fs.stat(this.assetsDirInput);
134
+ if (!stat.isDirectory())
135
+ throw new Error(`assets directory does not exist: ${this.assetsDirInput}`);
136
+ this.assetsDir = await fs.realpath(this.assetsDirInput);
137
+ this.server = createServer((req, res) =>
138
+ this.#handleHttp(req, res).catch((error) => {
139
+ this.logger.error?.(error);
140
+ if (!res.headersSent) this.#reply(res, 500, 'Internal server error');
141
+ else res.destroy();
142
+ })
143
+ );
144
+ this.wss = new WebSocketServer({ noServer: true, maxPayload: 8 * 1024 });
145
+ this.server.on('upgrade', (req, socket, head) => this.#upgrade(req, socket, head));
146
+ await new Promise((resolve, reject) => {
147
+ this.server.once('error', reject);
148
+ this.server.listen(this.port, this.host, () => {
149
+ this.server.off('error', reject);
150
+ resolve();
151
+ });
152
+ });
153
+ this.port = this.server.address().port;
154
+ return this;
155
+ }
156
+ async importAsset(filePath, position) {
157
+ const normalizedPosition = validPosition(position);
158
+ const scene = this.#singleClient();
159
+ const asset = await this.#readAsset(filePath);
160
+ if (this.totalAssetBytes + asset.size > MAX_TOTAL_ASSET_BYTES)
161
+ throw new Error(
162
+ 'Local bridge asset memory limit (128 MiB) is full; restart the bridge to clear imported assets'
163
+ );
164
+ const id = randomUUID();
165
+ const requestId = randomUUID();
166
+ this.assets.set(id, asset);
167
+ this.totalAssetBytes += asset.size;
168
+ const message = JSON.stringify({
169
+ type: 'import',
170
+ requestId,
171
+ asset: {
172
+ id,
173
+ url: `/local-api/assets/${id}?token=${this.token}`,
174
+ name: asset.name,
175
+ mediaType: asset.mediaType,
176
+ },
177
+ position: normalizedPosition,
178
+ });
179
+ return new Promise((resolve, reject) => {
180
+ const fail = (error) => this.#settle(requestId, error);
181
+ const timeout = setTimeout(() => {
182
+ if (scene.readyState === WebSocket.OPEN)
183
+ scene.send(JSON.stringify({ type: 'import-cancel', requestId }));
184
+ fail(new Error('Browser did not acknowledge the import within 20 seconds'));
185
+ }, ACK_TIMEOUT_MS);
186
+ this.pending.set(requestId, { resolve, reject, timeout, client: scene, assetId: id });
187
+ try {
188
+ scene.send(message);
189
+ } catch (error) {
190
+ fail(error);
191
+ }
192
+ });
193
+ }
194
+ async stop() {
195
+ if (this.stopping) return this.stopping;
196
+ this.stopping = (async () => {
197
+ for (const requestId of [...this.pending.keys()])
198
+ this.#settle(requestId, new Error('Local bridge stopped'));
199
+ await Promise.all([...this.clients].map((client) => this.#closeClient(client)));
200
+ this.clients.clear();
201
+ this.assets.clear();
202
+ this.totalAssetBytes = 0;
203
+ if (this.wss) await new Promise((resolve) => this.wss.close(resolve));
204
+ if (this.server) await new Promise((resolve) => this.server.close(resolve));
205
+ this.wss = null;
206
+ this.server = null;
207
+ })();
208
+ return this.stopping;
209
+ }
210
+ #singleClient() {
211
+ if (this.clients.size === 0)
212
+ throw new Error(`No browser scene is connected. Open ${this.browserUrl}`);
213
+ if (this.clients.size !== 1)
214
+ throw new Error(
215
+ 'More than one browser scene is connected; close extra scenes before importing'
216
+ );
217
+ return this.clients.values().next().value;
218
+ }
219
+ async #closeClient(client) {
220
+ if (client.readyState === WebSocket.CLOSED) return;
221
+ await new Promise((resolve) => {
222
+ let finished = false;
223
+ const finish = () => {
224
+ if (finished) return;
225
+ finished = true;
226
+ clearTimeout(timeout);
227
+ resolve();
228
+ };
229
+ const timeout = setTimeout(() => {
230
+ client.terminate();
231
+ finish();
232
+ }, 30_000);
233
+ client.once('close', finish);
234
+ try {
235
+ client.close();
236
+ } catch {
237
+ client.terminate();
238
+ finish();
239
+ }
240
+ });
241
+ }
242
+ async #readAsset(filePath) {
243
+ if (typeof filePath !== 'string' || !filePath)
244
+ throw new Error('path must be a non-empty string');
245
+ let realPath;
246
+ try {
247
+ realPath = await fs.realpath(path.resolve(filePath));
248
+ } catch {
249
+ throw new Error('Asset file does not exist');
250
+ }
251
+ if (!isInside(realPath, this.assetsDir))
252
+ throw new Error('Asset path must stay inside --assets-dir (symlinks are checked)');
253
+ const mimeType = assetMime(realPath);
254
+ if (!mimeType) throw new Error('Only PNG, JPEG, WebP, MP4, WebM, and GLB files are allowed');
255
+ const handle = await fs.open(realPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
256
+ try {
257
+ const stat = await handle.stat();
258
+ if (!stat.isFile()) throw new Error('Asset path must be a regular file');
259
+ if (stat.size > MAX_ASSET_BYTES) throw new Error('Asset exceeds the 32 MiB size limit');
260
+ const buffer = Buffer.allocUnsafe(stat.size);
261
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
262
+ if (bytesRead !== buffer.length) throw new Error('Asset changed while it was being read');
263
+ if (path.extname(realPath).toLowerCase() === '.glb') assertSafeGlb(buffer);
264
+ return Object.freeze({
265
+ buffer,
266
+ size: buffer.length,
267
+ mimeType,
268
+ mediaType: assetKind(realPath),
269
+ name: path.basename(realPath),
270
+ });
271
+ } finally {
272
+ await handle.close();
273
+ }
274
+ }
275
+ #removeAsset(id) {
276
+ const asset = this.assets.get(id);
277
+ if (asset) {
278
+ this.assets.delete(id);
279
+ this.totalAssetBytes -= asset.size;
280
+ }
281
+ }
282
+ #settle(requestId, error, result) {
283
+ const pending = this.pending.get(requestId);
284
+ if (!pending) return;
285
+ this.pending.delete(requestId);
286
+ clearTimeout(pending.timeout);
287
+ if (error) {
288
+ this.#removeAsset(pending.assetId);
289
+ pending.reject(error);
290
+ } else pending.resolve(result);
291
+ }
292
+ #authorized(req, queryToken) {
293
+ const token = queryToken || req.headers.authorization?.replace(/^Bearer\s+/i, '');
294
+ const actual = typeof token === 'string' ? Buffer.from(token) : null;
295
+ const expected = Buffer.from(this.token);
296
+ return (
297
+ actual !== null &&
298
+ actual.byteLength === expected.byteLength &&
299
+ timingSafeEqual(actual, expected)
300
+ );
301
+ }
302
+ #hostIsLocal(req) {
303
+ return req.headers.host === `127.0.0.1:${this.port}`;
304
+ }
305
+ async #handleHttp(req, res) {
306
+ if (!this.#hostIsLocal(req)) return this.#reply(res, 403, 'Forbidden');
307
+ if (!['GET', 'HEAD'].includes(req.method))
308
+ return this.#reply(res, 405, 'Method not allowed', { Allow: 'GET, HEAD' });
309
+ let url;
310
+ try {
311
+ url = new URL(req.url, `http://${this.host}:${this.port}`);
312
+ } catch {
313
+ return this.#reply(res, 400, 'Bad request');
314
+ }
315
+ if (url.pathname === '/local-api/status') {
316
+ if (!this.#authorized(req)) return this.#reply(res, 403, 'Forbidden');
317
+ return this.#json(res, this.getStatus(), req.method === 'HEAD');
318
+ }
319
+ const match = url.pathname.match(/^\/local-api\/assets\/([0-9a-f-]+)$/i);
320
+ if (match) {
321
+ if (!this.#authorized(req, url.searchParams.get('token')))
322
+ return this.#reply(res, 403, 'Forbidden');
323
+ const asset = this.assets.get(match[1]);
324
+ if (!asset) return this.#reply(res, 404, 'Not found');
325
+ res.writeHead(200, {
326
+ 'Content-Type': asset.mimeType,
327
+ 'Content-Length': asset.size,
328
+ 'Cache-Control': 'no-store',
329
+ });
330
+ return req.method === 'HEAD' ? res.end() : res.end(asset.buffer);
331
+ }
332
+ return this.#static(url.pathname, res, req.method === 'HEAD');
333
+ }
334
+ async #static(urlPath, res, headOnly) {
335
+ let decoded;
336
+ try {
337
+ decoded = decodeURIComponent(urlPath);
338
+ } catch {
339
+ return this.#reply(res, 400, 'Bad request');
340
+ }
341
+ const requested = decoded.endsWith('/') ? `${decoded}index.html` : decoded;
342
+ const relative = requested.replace(/^\/+/, '');
343
+ const parts = relative.split('/');
344
+ if (
345
+ !relative ||
346
+ parts.some((part) => !part || part.startsWith('.') || BLOCKED_STATIC_SEGMENTS.has(part))
347
+ )
348
+ return this.#reply(res, 404, 'Not found');
349
+ const isRoot = STATIC_ROOTS.some(
350
+ (root) => relative === root || relative.startsWith(`${root}/`)
351
+ );
352
+ if (!isRoot && !STATIC_FILES.has(relative)) return this.#reply(res, 404, 'Not found');
353
+ const candidate = path.resolve(this.staticRoot, relative);
354
+ const root = STATIC_ROOTS.map((name) => path.resolve(this.staticRoot, name)).find(
355
+ (value) => candidate === value || isInside(candidate, value)
356
+ );
357
+ if (!root && !STATIC_FILES.has(relative)) return this.#reply(res, 404, 'Not found');
358
+ let real;
359
+ try {
360
+ real = await fs.realpath(candidate);
361
+ } catch {
362
+ return this.#reply(res, 404, 'Not found');
363
+ }
364
+ if ((root && !isInside(real, root) && real !== root) || (!root && real !== candidate))
365
+ return this.#reply(res, 404, 'Not found');
366
+ const stat = await fs.stat(real);
367
+ if (!stat.isFile()) return this.#reply(res, 404, 'Not found');
368
+ res.writeHead(200, {
369
+ 'Content-Type': this.#mime(real),
370
+ 'Content-Length': stat.size,
371
+ 'Cache-Control': 'no-store',
372
+ });
373
+ if (headOnly) return res.end();
374
+ res.end(await fs.readFile(real));
375
+ }
376
+ #upgrade(req, socket, head) {
377
+ let url;
378
+ try {
379
+ url = new URL(req.url, `http://${this.host}:${this.port}`);
380
+ } catch {
381
+ return this.#rejectUpgrade(socket);
382
+ }
383
+ const origin = `http://${this.host}:${this.port}`;
384
+ if (
385
+ !this.#hostIsLocal(req) ||
386
+ url.pathname !== '/local-api/live' ||
387
+ !this.#authorized(req, url.searchParams.get('token')) ||
388
+ req.headers.origin !== origin
389
+ )
390
+ return this.#rejectUpgrade(socket);
391
+ this.wss.handleUpgrade(req, socket, head, (client) => {
392
+ this.clients.add(client);
393
+ client.on('error', () => {});
394
+ client.on('close', () => {
395
+ this.clients.delete(client);
396
+ for (const [id, pending] of this.pending)
397
+ if (pending.client === client)
398
+ this.#settle(
399
+ id,
400
+ new Error('Browser scene disconnected before acknowledging the import')
401
+ );
402
+ });
403
+ client.on('message', (data) => this.#message(client, data));
404
+ });
405
+ }
406
+ #rejectUpgrade(socket) {
407
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
408
+ socket.destroy();
409
+ }
410
+ #message(client, data) {
411
+ let message;
412
+ try {
413
+ message = JSON.parse(data.toString());
414
+ } catch {
415
+ return;
416
+ }
417
+ if (message?.type !== 'import-result' || typeof message.requestId !== 'string') return;
418
+ const pending = this.pending.get(message.requestId);
419
+ if (!pending || pending.client !== client) return;
420
+ if (message.ok === true)
421
+ this.#settle(message.requestId, null, {
422
+ requestId: message.requestId,
423
+ delivered: true,
424
+ ...(typeof message.objectId === 'string' ? { objectId: message.objectId } : {}),
425
+ });
426
+ else
427
+ this.#settle(
428
+ message.requestId,
429
+ new Error(typeof message.error === 'string' ? message.error : 'Browser rejected the import')
430
+ );
431
+ }
432
+ #json(res, value, headOnly) {
433
+ const body = JSON.stringify(value);
434
+ res.writeHead(200, {
435
+ 'Content-Type': 'application/json; charset=utf-8',
436
+ 'Content-Length': Buffer.byteLength(body),
437
+ 'Cache-Control': 'no-store',
438
+ });
439
+ res.end(headOnly ? undefined : body);
440
+ }
441
+ #reply(res, status, body, headers = {}) {
442
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers });
443
+ res.end(body);
444
+ }
445
+ #mime(file) {
446
+ return (
447
+ assetMime(file) ||
448
+ {
449
+ '.html': 'text/html; charset=utf-8',
450
+ '.js': 'text/javascript; charset=utf-8',
451
+ '.css': 'text/css; charset=utf-8',
452
+ '.svg': 'image/svg+xml',
453
+ '.ico': 'image/x-icon',
454
+ '.webmanifest': 'application/manifest+json',
455
+ }[path.extname(file)] ||
456
+ 'application/octet-stream'
457
+ );
458
+ }
459
+ }
460
+ export { ACK_TIMEOUT_MS, MAX_ASSET_BYTES, MAX_TOTAL_ASSET_BYTES };
@@ -0,0 +1,107 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import { LocalAssetBridge } from './asset-bridge.js';
5
+
6
+ function options(argv) {
7
+ const result = { port: 0 };
8
+ for (let index = 0; index < argv.length; index += 1) {
9
+ const value = argv[index];
10
+ if (value === '--assets-dir') result.assetsDir = argv[++index];
11
+ else if (value === '--port') result.port = Number(argv[++index]);
12
+ else throw new Error(`Unknown argument: ${value}`);
13
+ }
14
+ if (!result.assetsDir)
15
+ throw new Error('Usage: chocodrop-mcp --assets-dir <directory> [--port <port>]');
16
+ if (!Number.isInteger(result.port) || result.port < 0 || result.port > 65535)
17
+ throw new Error('--port must be a valid port number');
18
+ return result;
19
+ }
20
+
21
+ function text(value, isError = false) {
22
+ return { content: [{ type: 'text', text: value }], ...(isError ? { isError: true } : {}) };
23
+ }
24
+
25
+ function validateImport(args) {
26
+ if (!args || typeof args.path !== 'string' || !args.path)
27
+ throw new Error('path must be a non-empty string');
28
+ if (
29
+ args.position !== undefined &&
30
+ (!args.position ||
31
+ typeof args.position !== 'object' ||
32
+ !['x', 'y', 'z'].every((key) => Number.isFinite(args.position[key])))
33
+ )
34
+ throw new Error('position must contain finite x, y, and z numbers');
35
+ return args;
36
+ }
37
+
38
+ export async function runMcpServer(argv = process.argv.slice(2), bridgeOptions = {}) {
39
+ const bridge = await new LocalAssetBridge({ ...options(argv), ...bridgeOptions }).start();
40
+ process.stderr.write(`ChocoDrop local bridge: ${bridge.browserUrl}\n`);
41
+ const server = new Server(
42
+ { name: 'chocodrop-local', version: '1.0.0' },
43
+ { capabilities: { tools: {} } }
44
+ );
45
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
46
+ tools: [
47
+ {
48
+ name: 'get_status',
49
+ description: 'Get the local ChocoDrop scene URL and connection state.',
50
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
51
+ },
52
+ {
53
+ name: 'import_asset',
54
+ description: 'Import one allowed local asset into the connected ChocoDrop browser scene.',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ path: { type: 'string', minLength: 1 },
59
+ position: {
60
+ type: 'object',
61
+ properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } },
62
+ required: ['x', 'y', 'z'],
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ required: ['path'],
67
+ additionalProperties: false,
68
+ },
69
+ },
70
+ ],
71
+ }));
72
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
73
+ if (request.params.name === 'get_status') return text(JSON.stringify(bridge.getStatus(), null, 2));
74
+ if (request.params.name !== 'import_asset')
75
+ return text(`Unknown tool: ${request.params.name}`, true);
76
+ try {
77
+ const { path, position } = validateImport(request.params.arguments);
78
+ return text(JSON.stringify(await bridge.importAsset(path, position)));
79
+ } catch (error) {
80
+ return text(error.message, true);
81
+ }
82
+ });
83
+ const transport = new StdioServerTransport();
84
+ let shuttingDown = false;
85
+ const shutdown = async () => {
86
+ if (shuttingDown) return;
87
+ shuttingDown = true;
88
+ await bridge.stop();
89
+ await server.close();
90
+ };
91
+ await server.connect(transport);
92
+ server.onclose = () => {
93
+ shutdown().catch((error) => process.stderr.write(`${error.message}\n`));
94
+ };
95
+ process.stdin.once('end', () =>
96
+ shutdown().catch((error) => process.stderr.write(`${error.message}\n`))
97
+ );
98
+ process.once('SIGINT', () =>
99
+ shutdown().catch((error) => process.stderr.write(`${error.message}\n`))
100
+ );
101
+ process.once('SIGTERM', () =>
102
+ shutdown().catch((error) => process.stderr.write(`${error.message}\n`))
103
+ );
104
+ return { bridge, server, shutdown };
105
+ }
106
+
107
+ export { options, validateImport };